aboutsummaryrefslogtreecommitdiff
path: root/bpkg-util/manage.in
blob: 65ab378ba18d9f2ef167071dbd4da563b48fe4a7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
#!/usr/bin/env bash

# file      : bpkg-util/manage.in
# license   : MIT; see accompanying LICENSE file

# Interactively migrate newly-submitted packages from a source git repository
# to a destination git repository.
#
# Present the user with the list of commits that added the files currently in
# the source repository's working directory and ask the user to select from a
# menu the action to perform on a selection of these commits.
#
# As the files added by these commits are pending a move to the destination
# repository, these commits will be referred to as "pending" commits.
#
# Actions that can be performed on a selection of pending commits include
# moving them from the source repository to a single commit in the destination
# repository and dropping them from the source repository.
#
# The flow of this script, in broad strokes, is as follows: for each file in
# the source repository directory, find the hash of the commit that added it;
# these are the pending commits. Arrange the pending commits in chronological
# order. Display to the user the pending commits along with the files they
# added. Let the user select one or more pending commits and an action to be
# performed on them. Each successful action results in a commit to the source
# and/or destination repositories and leaves both repositories in a clean
# state. Once the action has been performed, redisplay the updated pending
# commit list and prompt for the next action. Pushing to the remote
# repositories, a supported operation, can be done at any time during the
# session.
#
# <dir> The directory into which the source and destination repositories have
#       been checked out. If not specified, current directory is assumed.
#
usage="usage: $0 [<dir>]"

# Source/destination repository inside <dir>. Note: also used in commit
# messages.
#
src_repo_name=queue
dst_repo_name=public

owd="$(pwd)"
trap "{ cd '$owd'; exit 1; }" ERR
set -o errtrace # Trap in functions.

@import bpkg-util/utility@

# Use the bpkg program from the script directory, if present. Otherwise, use
# just 'bpkg'.
#
bpkg_util_bpkg="$(dirname "$(realpath "${BASH_SOURCE[0]}")")/bpkg"

if [ ! -x "$bpkg_util_bpkg" ]; then
  bpkg_util_bpkg=bpkg
fi

@import bpkg-util/package-archive@

# Set the working directory.
#
if [ $# -eq 0 ]; then
  dir="$owd"
elif [ $# -eq 1 ]; then
  dir="${1%/}" # <dir> with trailing slash removed.
else
  error "$usage"
fi

# The source and destination package repository directories.
#
# Note that, throughout this script, any path not explicitly prefixed with
# "$src_dir/" or "$dst_dir/" is relative to the root of the source or
# destination package repositories.
#
src_dir="$dir/$src_repo_name"
dst_dir="$dir/$dst_repo_name"

if [ ! -d "$src_dir" ]; then
  error "'$src_dir' does not exist or is not a directory"
fi

if [ ! -d "$dst_dir" ]; then
  error "'$dst_dir' does not exist or is not a directory"
fi

# Check that both git repositories are clean.
#
if [ -n "$(git -C $src_dir status --porcelain)" ]; then
  error "git repository in '$src_dir' is not clean"
fi

if [ -n "$(git -C $dst_dir status --porcelain)" ]; then
  error "git repository in '$dst_dir' is not clean"
fi

# Use run() to show the user that git is the source of the diagnostics.
# "Already up to date", for example, is too vague.
#
run git -C "$src_dir" pull >&2
run git -C "$dst_dir" pull >&2

# Load the source and destination repositories' submit configurations (section
# name/directory mappings and owners directory path).
#
# Each repository's settings are sourced into the temporary variables 'owners'
# and 'sections' and copied from there to source- and destination-specific
# variables.
#
declare owners
declare -A sections
source "$src_dir/submit.config.bash"

src_owners="$owners"
declare -A src_sections
for s in "${!sections[@]}"; do
  src_sections["$s"]="${sections[$s]}"
done

owners=
sections=()
source "$dst_dir/submit.config.bash"

dst_owners="$owners"
declare -A dst_sections
for s in "${!sections[@]}"; do
  dst_sections["$s"]="${sections[$s]}"
done

# Find all archive and owner manifest files in the source repository.
#
# Every file in a repository section directory except *.manifest is a package
# archive and every file in the owners directory is a project or package owner
# manifest. Therefore run find separately on each section directory and the
# owners directory to build a list containing only package-related files.
#
# Store the relative to the repository directory file paths in an array used
# to build the set of pending commits.
#
src_files=()
for s in "${src_sections[@]}"; do
  while read f; do
    src_files+=("${f#$src_dir/}")
  done < <(find "$src_dir/$s" -type f -not -name "*.manifest")
done

if [[ -n "$src_owners" && -d "$src_dir/$src_owners" ]]; then
  while read f; do
    src_files+=("${f#$src_dir/}")
  done < <(find "$src_dir/$src_owners" -type f)
fi

# Build the set of pending commit hashes ("pending set").
#
# For each file in the source repository, find the most recent commit that
# added it and store its abbreviated hash (as key) inside the 'pending_set'
# associative array (note: unordered) and (as value) inside the 'file_commits'
# associative array.
#
# 'file_commits' maps from the file path to the last commit to add it to the
# repository. A file may have been added and removed by earlier commits and
# could thus be migrated with the wrong commit unless care is taken (see
# migrate() for an example).
#
declare -A pending_set
declare -A file_commits
for f in "${src_files[@]}"; do

  # -n 1:                limit output to one commit (that is, the most recent)
  # --diff-filter=A:     only show commits that added files
  # --pretty=format:%h:  output only the abbreviated commit hash
  #
  h="$(git -C "$src_dir" log -n 1 --diff-filter=A --pretty=format:%h -- "$f")"

  # Note that the hash cannot be empty because, after our clean checks at the
  # top, every file on disk must have been added by some commit (that is,
  # there can be no untracked files).
  #
  pending_set["$h"]=true
  file_commits["$f"]="$h"
done

# Arrange the pending commits in the chronological order.
#
# Go through the most recent commits in the git log which added one or more
# files, skipping those not present in the pending set and keeping count to
# bail out as soon as we ordered all of them.
#
pending_seq=()
for (( i=0; i != ${#pending_set[@]}; )); do
  read h # The abbreviated commit hash.

  # If this is a pending commit, prepend its hash to the ordered array.
  #
  if [ "${pending_set[$h]}" ]; then
    pending_seq=("$h" "${pending_seq[@]}")
    ((++i))
  fi
done < <(git -C "$src_dir" log --diff-filter=A --pretty=format:%h)

if [ "${#pending_seq[@]}" -eq 0 ]; then
  info "Good news, nothing to manage!"
  exit 0
fi

# Return the list of files a commit added to the source repository.
#
function commit_files () # <commit-hash>
{
  local h="$1"

  # git-diff-tree arguments:
  #
  # --diff-filter=A:  select only files that were added.
  # -z:               don't munge file paths and separate output fields with
  #                   NULs.
  # -r:               recurse into subtrees (directories).
  #
  git -C "$src_dir" diff-tree \
      --no-commit-id --name-only --diff-filter=A -z -r \
      "$h"
}

# Extract the package name, version, and project from a package archive's
# manifest and print it to stdout in the '<name> <version> <project>' form. If
# the manifest does not specify the project name, the package name is returned
# as the project name.
#
function extract_pkg_info () # <archive>
{
  local arc="$1"

  local r
  r=($(bpkg_util_pkg_verify_archive "$arc")) # <name> <version> <project>
  if [ ! -v r[2] ]; then
    r[2]="${r[0]}"
  fi

  # Verify that the archive parent directory name matches the project.
  #
  local p="${r[2]}"
  if [ "$p" != "$(basename "$(dirname "$arc")")" ]; then
    error "'$arc' archive directory name does not match package project '$p'"
  fi

  echo -n "${r[@]}"
}

# Migrate a package archive or ownership manifest file from the source
# repository to the destination repository.
#
# <src> is the path of the source file, relative to the source respository
# directory. For example, '1/stable/foo/foo-1.2.3.tar.gz',
# 'owners/foo/project-owner.manifest', or
# 'owners/foo/foo/package-owner.manifest'.
#
# <dst> is the path of the destination directory, relative to the destination
# repository directory. For example, '1/testing/foo', 'ownership/foo', or
# 'ownership/foo/foo'.
#
# Note that the source and destination sections and owners directories may
# differ (as they do in these examples) which is why those components must
# be specified in both the source and destination paths.
#
# Move the file from the source repository directory to the destination
# repository directory, creating directories if required; stage the addition
# of the file to the destination repository; stage the removal of the file
# from the source repository.
#
function migrate_file () # <src> <dst>
{
  local src="$1"
  local dst="$2"

  mkdir -p "$dst_dir/$dst"
  mv "$src_dir/$src" "$dst_dir/$dst/"
  run git -C "$src_dir/" rm --quiet "$src"
  run git -C "$dst_dir/" add "$dst/$(basename "$src")"
}

# Migrate:
#
#  0. Assumptions:
#
#    - All the packages in a bundle are migrating from/to the same sections
#      (enforce source part).
#
#    - All the packages are from the same project (enforce).
#
#  1. Move files:
#
#    - Owners to owners directory.
#
#    - Packages into corresponding sections:
#
#      alpha -> alpha
#      beta  -> beta
#      stable -> testing|stable
#
#      Bonus: replace revisions.
#      Bonus: offer to drop existing packages if moving to alpha or beta.
#
#  2. Come up with commit message for src and commit.
#
#    "Migrate <project> to $dst_repo_name/<section>"
#
#      "remove <package>/<version>"
#      "remove owners/<project>/*"
#
#  3. Come up with commit message for dst and commit.
#
#    "Migrate <project> from $src_repo_name/<section>"
#
#      "add <package>/<version>"
#      "replace <package>/<version> with <version>"     (if replacing)
#      "add owners/<project>/*"
#
#  4. Commit.
#
# Note that when migrating we will need to confirm with git that each of a
# commit's added files were actually most recently added by that commit. For
# example (oldest commits first):
#
#  commit 1: add foo.tar.gz, bar.tar.gz
#  commit 2: del foo.tar.gz
#  commit 3: add foo.tar.gz
#
# If the user chooses to migrate commit 1, only bar.tar.gz must be migrated,
# despite foo.tar.gz existing on disk.
#
# The commit bundle associative array is the set of selected pending
# commits. Its keys are the corresponding indexes of the 'pending_seq' array
# (but offset by +1 and formatted to match the displayed commit numbers).
# Note: the reason the commit bundle is an associative array is to prevent
# duplicates.
#
declare -A bundle

# Migrate the selected commit bundle from the source repository to the
# destination repository. Set the global migrate_result variable to true if
# the migration has been successful, or issue appropriate diagnostics and set
# it to the empty string if any of the following is true:
#
# - The commit bundle is empty.
#
# - Files added by commits in the bundle are not from the same project (the
#   "bundle project") or, in the case of archives, the same repository section
#   (the "bundle section").
#
# - The required section does not exist in the destination repository.
#
# - An identical package archive (same name and version) already exists in the
#   destination repository.
#
# - Any file has an invalid path (for example, missing a valid project or
#   section component).
#
# The migration process proceeds as follows:
#
# - Move files: all of the files in the selected commit bundle are moved from
#   the source repository into the destination repository.
#
#   Package archives may be removed and ownership manifests overwritten at the
#   destination. Candidate files for replacement are selected as follows:
#
#   - In the alpha and beta sections, any package archive files in the
#     destination section directory belonging to the same package are
#     considered for replacement, regardless of their versions.
#
#   - In other sections, any package archives in the destination section
#     directory with the same name and version but a different revision
#     (currently whether lower or higher) are automatically replaced.
#
#   - Project or package ownership manifests will be replaced (that is, simply
#     overwritten) at the destination with any ownership manifests added by
#     the commit bundle because their presence implies that ownership
#     information has changed.
#
#   Stage (but don't commit) the removal of the files from the source
#   repository and their addition to the destination repository.
#
# - Make commits to the source and destination respositories with appropriate
#   commit messages.
#
# If any part of the migration fails then all changes to the source and
# destination repositories are undone, leaving two clean repositories.
#
function migrate ()
{
  migrate_result=

  if [ "${#bundle[@]}" -eq 0 ]; then
    info "no commits selected"
    return
  fi

  # Check that every commit's added files are in the bundle section and/or
  # bundle project before migrating any of them. Build the bundle's list of
  # files as we go along, classifying them as package archives or ownership
  # manifests based on their paths.
  #
  # The bundle section is derived from the first package archive encountered
  # and the bundle project from the first package archive or owner manifest
  # encountered.
  #
  # Note that the bundle traversal is unordered.
  #
  local src_sect=          # Source section name.
  local src_sect_dir=      # Source section directory.
  local proj=              # The bundle (source) project.
  local pkgs=()            # The bundle's archive files.
  local owns=()            # The bundle's ownership manifests.

  local i
  for i in "${!bundle[@]}"; do
    local h="${pending_seq[i-1]}" # The current commit's abbreviated hash.

    # Check the files added by the current commit.
    #
    local f
    while read -d '' f; do
      if [ "${file_commits[$f]}" != "$h" ]; then
        continue # This file was deleted by a subsequent commit.
      fi

      # Derive the project and/or section names from the file path.
      #
      # The project name is taken directly from the file path. In the case of
      # package archives, the section name is the key in the 'src_sections'
      # associative array which maps to the section directory extracted from
      # the file path.
      #
      local fproj= # Current file's project.

      if [[ -n "$src_owners" && ("$f" =~ ^"$src_owners"/([^/]+)/.+$) ]]; then
        fproj="${BASH_REMATCH[1]}"
        owns+=("$f")
      elif [[ "$f" =~ ^(.+)/([^/]+)/[^/]+$ ]]; then  # Package archive?
        local fsect_dir="${BASH_REMATCH[1]}"

        fproj="${BASH_REMATCH[2]}"
        pkgs+=("$f")

        # Find the archive section name associated with the extracted section
        # directory in 'src_sections' (a value-to-key lookup).
        #
        local fsect=

        # The "*" key is a catch-all for unknown submitted section names and,
        # if present, will share a value (section directory) with one of the
        # known section names and therefore must be skipped.
        #
        # If there is no mapping in 'src_sections' to the extracted section
        # directory then the file path is invalid.
        #
        local k
        for k in "${!src_sections[@]}"; do
          if [[ ("${src_sections[$k]%/}" == "$fsect_dir") &&
                ("$k" != "*") ]]; then
            fsect="$k"    # Current file's section name.
            break
          fi
        done

        if [ -z "$fsect" ]; then
          info "unable to find section name for file '$f'"
          return
        fi

        # Set the source section name and directory if unset; otherwise fail
        # if the current file is not from the source section.
        #
        if [ -z "$src_sect" ]; then
          src_sect="$fsect"
          src_sect_dir="$fsect_dir"
        elif [ "$fsect" != "$src_sect" ]; then
          info "cannot include commit $i: '$f' is not in section $src_sect"
          return
        fi
      else
        info "unrecognized type of file '$f'"
        return
      fi

      # Set the bundle project if unset; otherwise fail if the current file is
      # not from the bundle project.
      #
      # Note: $fproj cannot be empty here (see above).
      #
      if [ -z "$proj" ]; then
        proj="$fproj"
      elif [ "$fproj" != "$proj" ]; then
        info "cannot include commit $i: '$f' is not in project $proj"
        return
      fi
    done < <(commit_files "$h")
  done

  # Finalize migration variables the values of which depend on whether the
  # bundle contains at least one package archive or ownership manifests only.
  #
  # The source and destination commit messages are composed incrementally as
  # the migration process proceeds.
  #
  local dst_sect     # Destination section name.
  local dst_sect_dir # Destination section directory.
  local src_cmsg     # Source commit message.
  local dst_cmsg     # Destination commit message.

  if [ ${#pkgs[@]} -ne 0 ]; then # Bundle contains package archive(s).
    dst_sect="$src_sect"

    # If it exists, 'testing' overrides 'stable' at the destination.
    #
    if [[ ("$dst_sect" == "stable") && -v dst_sections["testing"] ]]; then
      dst_sect="testing"
    fi

    # Fail if the target section does not exist in the destination repository.
    #
    if [ ! -v dst_sections["$dst_sect"] ]; then
      info "section '$dst_sect' does not exist in the destination repository"
      return
    fi

    dst_sect_dir="${dst_sections[$dst_sect]}"

    src_cmsg="Migrate $proj to $dst_repo_name/$dst_sect"$'\n\n'
    dst_cmsg="Migrate $proj from $src_repo_name/$src_sect"$'\n\n'
  else                           # Bundle consists only of ownership manifests.

    # The setup where the ownership authentication is disabled on the
    # destination but enabled on source is probably obscure, but let's
    # consider it possible since the submit-git handler allows such a setup.
    #
    if [ -n "$dst_owners" ]; then
      src_cmsg="Migrate $proj ownership info to $dst_repo_name"$'\n\n'
      dst_cmsg="Migrate $proj ownership info from $src_repo_name"$'\n\n'
    else
      src_cmsg="Remove $proj ownership info"$'\n\n'
      dst_cmsg= # Nothing to commit.
    fi
  fi

  # Ensure that the source and destination repositories are clean if the
  # migration of any file fails.
  #
  # Note that the source repository cannot have untracked files so we
  # git-clean only the destination repository.
  #
  function cleanup ()
  {
    info "migration failed; resetting and cleaning repositories"

    if ! run git -C "$src_dir" reset --hard ||
       ! run git -C "$dst_dir" reset --hard ||
       ! run git -C "$dst_dir" clean --force; then
      info "failed to reset/clean repositories -- manual intervention required"
    fi
  }
  trap cleanup EXIT

  # Migrate the bundle's package archive files.
  #
  for f in "${pkgs[@]}"; do
    # Get the current package's name and version from its embedded manifest
    # (we already have the source project in $proj).
    #
    local p
    p=($(extract_pkg_info "$src_dir/$f"))

    local name="${p[0]}"
    local src_version="${p[1]}"

    # Check for duplicate package in all sections. Use <name>-<version>.*
    # without .tar.gz in case we want to support more archive types later.
    #
    # Note that, for example, foo-bar version 1.0 and foo version bar-1.0 have
    # the same archive name foo-bar-1.0.tar.gz.
    #
    local s
    for s in "${!dst_sections[@]}"; do
      local p
      IFS=$'\n' eval \
      'p=($(bpkg_util_pkg_find_archive "$name-$src_version.*" \
                                       "$dst_dir/${dst_sections[$s]}"))'

      if [ "${#p[@]}" -ne 0 ]; then
        local n="${p[0]}"
        local v="${p[1]}"
        local a="${p[3]}"

        if [ "$n" == "$name" ]; then
          error "duplicate of $name/$src_version at '$a'"
        else
          error "conflict of $name/$src_version with $n/$v at '$a'"
        fi
      fi
    done

    # In the destination repository, find and remove package archive files
    # which are other alpha/beta versions or revisions of the current source
    # package.
    #
    local vpat # Version pattern.
    case "$dst_sect" in
      alpha|beta) vpat="*"              ;; # All package versions.
      *)          vpat="$src_version*"  ;; # All package version revisions.
    esac

    # Packages in the destination repository to be considered for replacement.
    #
    local dst_files

    IFS=$'\n' eval \
    'dst_files=($(bpkg_util_pkg_find_archives "$name" \
                                              "$vpat" \
                                              "$dst_dir/$dst_sect_dir"))'

    # If true, the source package replaces one or more packages in the
    # destination repository.
    #
    local repl=

    local dst_f
    for dst_f in "${dst_files[@]}"; do
      local p
      p=($(extract_pkg_info "$dst_f"))

      local dst_version="${p[1]}"
      local dst_project="${p[2]}"

      # Ask whether or not to drop the current destination package.
      #
      # Include the project names in the prompt if the destination package's
      # project differs from that of the source package.
      #
      local src="$src_version"
      local dst="$name/$dst_version"
      if [ "$dst_project" != "$proj" ]; then
        src+=" ($proj)"
        dst+=" ($dst_project)"
      fi

      while true; do
        read -p "replace $dst with $src? [y/n]: " opt

        case "$opt" in
          "y")
            repl=true
            dst_cmsg+="  replace $name/$dst_version with $src_version"$'\n'
            run git -C "$dst_dir" rm --quiet "${dst_f#$dst_dir/}"
            break
            ;;
          "n")
            break
            ;;
        esac
      done
    done

    # Migrate the current package.
    #
    src_cmsg+="  remove $name/$src_version"$'\n'
    if [ ! "$repl" ]; then
      dst_cmsg+="  add $name/$src_version"$'\n'
    fi
    migrate_file "$f" "$dst_sect_dir/$proj"
  done

  # Migrate the bundle's ownership manifests.
  #
  # If ownership authentication is disabled on the destination repository,
  # only remove ownership manifests from the source repository (that is, don't
  # migrate).
  #
  for f in "${owns[@]}"; do
    src_cmsg+="  remove $(dirname $f)/*"$'\n'

    if [ -n "$dst_owners" ]; then
      local dp=$(dirname "${f/$src_owners/$dst_owners}")  # Destination path.

      # Let the commit message reflect whether this is a new ownership
      # manifest or is replacing an existent one.
      #
      if [ ! -e "$dst_dir/$dp/$(basename "$f")" ]; then
        dst_cmsg+="  add $dp/*"$'\n'
      else
        dst_cmsg+="  update $dp/*"$'\n'
      fi

      migrate_file "$f" "$dp"
    else
      run git -C "$src_dir/" rm --quiet "$f"
    fi
  done

  # Commit the changes made to the source and destination repositories.
  #
  info
  run git -C "$src_dir" commit -m "$src_cmsg"

  if [ -n "$dst_cmsg" ]; then
    info
    run git -C "$dst_dir" commit -m "$dst_cmsg"
  fi

  info

  # Remove the migrated commits from the pending sequence and clear the
  # bundle.
  #
  for i in "${!bundle[@]}"; do
    unset pending_seq[i-1]
  done
  pending_seq=("${pending_seq[@]}") # Remove the gaps created by unset.
  bundle=()

  migrate_result=true

  # All files have been migrated successfully so clear the EXIT trap.
  #
  trap EXIT

  # Pause to give the operator a chance to look at the commits before the list
  # of remaining pending commits is displayed.
  #
  read -p "Press Enter to continue: "
}

# Push local changes to the remote source and/or destination git repositories.
#
# Push to the destination repository first because thus the migrated files
# will be in both remote repositories until the completion of the subsequent
# push to the source repository (which may fail or take long). Although this
# is an inconsistent state, it is safe because other programs such as a
# submission handler will be able to detect the duplicates and therefore
# refuse to do anything. If, on the other hand, we pushed to the source first,
# the migrated files would not exist in either remote repository until the
# push to the destination repository completed. In this state the submission
# handler would, for example, accept a resubmission of the migrated packages or
# erroneously establish ownership for already owned project/package names.
#
function push ()
{
  # Let's print additional diagnostics on git-push failure, to emphasize for
  # the user which of the two repositories we have failed to push.
  #
  if ! run git -C "$dst_dir" push; then
    error "push to $dst_repo_name failed"
  fi

  if ! run git -C "$src_dir" push; then
    error "push to $src_repo_name failed"
  fi
}

# Present the list of pending commits to the user, oldest first, marking files
# that were deleted by subsequent commits with `*`:
#
# 001 (deadbeef) Add libfoo/1.2.3
#
#     1/testing/foo/libfoo-1.2.3.tar.gz
#     owners/foo/project-owner.manifest
#     owners/foo/libfoo/package-owner.manifest
#
# 002 (c00l0fff) Add bar/1.2.3
#
#   * 1/testing/bar/libbar-1.2.3.tar.gz
#     1/testing/bar/libbaz-1.2.3.tar.gz
#
# 003 (deadbabe) Add libbar/1.2.3+1
#
#     1/testing/bar/libbar-1.2.3+1.tar.gz
#
# Note that files deleted by subsequent commits may still be in the
# repository. See migrate() for an example.
#
# Then prompt the user for the action (showing the current bundle):
#
# [001 002][<N>,m,c,p,q,l,?]:
#
# <N> - add commit to the commit bundle
#  m  - migrate the selected commit bundle
#  c  - clear the selected commit bundle
#  p  - push source and destination repositories
#  l  - print pending commits
#  q  - quit (prompting to push if any actions have been taken)
#  ?  - print this help
#
# The user interaction loop.
#
# In each iteration, present the list of pending commits, display the menu of
# actions, read the user's input, and perform the chosen action.
#
# True if any changes have been made to the source and/or destination git
# repositories (in which case the user will be asked whether or not to push
# before quitting).
#
need_push=

while true; do
  # Show the pending commits.
  #
  if [ "${#pending_seq[@]}" -eq 0 ]; then
    info "no more pending commits"
  fi

  for ((i=0; i != "${#pending_seq[@]}"; i++)); do
    h="${pending_seq[$i]}"

    # Print commit number, hash, and subject.
    #
    # The commit number is left-padded with 0s to 3 digits. Prefix with a
    # newline to separate the first commit from the git-pull output and the
    # rest from the previous commit info block.
    #
    subj="$(git -C "$src_dir" log -n 1 --pretty=format:%s "$h")"
    printf "\n%.3d (%s) %s\n\n" "$((i+1))" "$h" "$subj" >&2

    # Print this commit's files.
    #
    # Fetch from the git repository the list of files added by the current
    # commit. Print each file's path and, if it was deleted by a subsequent
    # commit, mark with an asterisk.
    #
    # Note that 'file_commits' is populated above from the list of files
    # currently in the source repository. Therefore, if git says a file was
    # added by a commit but it is associated with a different commit hash in
    # 'file_commits' it means the file was deleted and added back by later
    # commits; and if there is no mapping for the file it means it was deleted
    # but not added back (that is, it's no longer in the repository). So we
    # mark the re-added file with an exclamation.
    #
    while read -d '' f; do
      if [ "${file_commits[$f]}" == "$h" ]; then
        info "    $f"  # File was last added by the current commit.
      elif [ -v file_commits["$f"] ]; then
        info "  ! $f"  # File was deleted and added back by subsequent commits.
      else
        info "  * $f"  # File was deleted but not added back.
      fi
    done < <(commit_files "$h")
  done

  # Prompt the user for the action (showing the current bundle), get user
  # input, and perform the selected action.
  #
  # Note that we could adapt the menu according to the current state (don't
  # offer to migrate if the bundle array is empty, etc) but let's not
  # complicate the logic.
  #
  # Breaking out of this loop prints the pending commit list again.
  #
  while true; do
    # Sort commit bundle in ascending order.
    #
    # Expand the 'bundle' associative array's keys into a single word in which
    # they are separated by spaces (the first member of IFS) using the
    # ${!a[*]} syntax; replace each space with a newline before piping to
    # 'sort', which is newline-based; finally collect sort's output into an
    # array using the a=() syntax, which splits on newline (the last member of
    # IFS) because neither space nor tab characters (the other members of IFS)
    # can occur in the keys.
    #
    bundle_sorted=($(sed 's/ /\n/g' <<<"${!bundle[*]}" | sort -))

    printf "\n"
    read -p "[${bundle_sorted[*]}][<N>,m,c,p,l,q,?]: " opt

    case "$opt" in
      # Add commit to bundle.
      #
      [0-9]*)
        if [[ ("$opt" -gt 0) && ("$opt " -le "${#pending_seq[@]}") ]]; then
          printf -v opt "%.3d" "$opt" # Format as in pending commit list.
          if [ ! -v bundle["$opt"] ]; then
            bundle["$opt"]=true
            info "commit $opt (${pending_seq[$opt-1]}) added to selected bundle"
          else
            info "commit $opt is already in the bundle"
          fi
        else
          info "non-existent commit number $opt"
        fi
        ;;
      # Migrate the commit bundle.
      #
      m)
        migrate
        if [ "$migrate_result" ]; then
          need_push=true
          break
        fi
        ;;
      # Clear the commit bundle.
      #
      c)
        bundle=()
        break
        ;;
      # Push changes.
      #
      p)
        push
        need_push=
        break
        ;;
      # Redraw the pending commit list.
      #
      l)
        break
        ;;
      # Quit.
      #
      q)
        if [ ! "$need_push" ]; then
          exit 0
        fi

        while true; do
          read -p "push changes? [y/n/(c)ancel]: " opt

          case "$opt" in
            "c")
              break             # Print options menu again.
              ;;
            "y")
              push
              exit 0
              ;;
            "n")
              exit 0
              ;;
            *)
              continue
              ;;
          esac
        done
        ;;
      # ? or invalid option: print menu.
      #
      *)
        cat <<-EOF

 <N> - add commit to the commit bundle
  m  - migrate the selected commit bundle
  c  - clear the selected commit bundle
  p  - push source and destination repositories
  l  - print pending commits
  q  - quit (prompting to push if any actions have been taken)
  ?  - print this help
EOF
        ;;
    esac
  done
done