1 //#!/usr/bin/java --source 17
  2 package shebang.netbeans;
  3 // last updated: 2023-07-30
  4 
  5 import java.io.ByteArrayOutputStream;
  6 import java.io.IOException;
  7 import java.nio.file.FileVisitResult;
  8 import java.nio.file.FileVisitor;
  9 import java.nio.file.Files;
 10 import java.nio.file.Path;
 11 import java.nio.file.Paths;
 12 import java.nio.file.SimpleFileVisitor;
 13 import java.nio.file.attribute.BasicFileAttributes;
 14 import java.util.ArrayList;
 15 import java.util.Collection;
 16 import java.util.LinkedList;
 17 import java.util.List;
 18 import java.util.Objects;
 19 import java.util.Queue;
 20 import java.util.Set;
 21 import java.util.SortedSet;
 22 import java.util.TreeSet;
 23 import java.util.stream.Stream;
 24 
 25 import static java.nio.file.FileVisitResult.*;
 26 import static java.nio.file.StandardCopyOption.*;
 27 import static java.util.function.Predicate.not;
 28 
 29 /**
 30  * Java shebang script to replicate the state of a source directory tree to a
 31  * target directory which is under version control. Supported version control
 32  * systems are subversion and git.
 33  *
 34  * @author Bernd Michaely (info@bernd-michaely.de)
 35  */
 36 public class SynchronizeDirWithVcsWorkingCopy
 37 {
 38   private Path baseDirSrc, baseDirDst;
 39   private Action action;
 40   private final List<SystemCommand> systemCommands = new ArrayList<>();
 41   private boolean validPaths;
 42   private final SortedSet<Path> srcFiles = new TreeSet<>();
 43   private final SortedSet<Path> srcDirectories = new TreeSet<>();
 44   private final SortedSet<Path> dstFiles = new TreeSet<>();
 45   private final SortedSet<Path> dstDirectories = new TreeSet<>();
 46 
 47   private enum Action
 48   {
 49     SHOW_HELP, DRY_RUN, SVN, GIT
 50   }
 51 
 52   private enum Op
 53   {
 54     MKDIR, ADD, MOD, DEL, RMDIR
 55   }
 56 
 57   /**
 58    * Interface to describe the elementary actions to perform.
 59    */
 60   private interface SystemCommand
 61   {
 62     /**
 63      * Add a dir, which is present in the src, but not in the dst.
 64      *
 65      * @param subPath the relative dir path to add
 66      * @throws ScriptException if an {@link IOException} occurs or running a
 67      *                         system command fails
 68      */
 69     void addDir(Path subPath);
 70 
 71     /**
 72      * Add a file, which is present in the src, but not in the dst.
 73      *
 74      * @param subPath the relative file path to add
 75      * @throws ScriptException if an {@link IOException} occurs or running a
 76      *                         system command fails
 77      */
 78     void addFile(Path subPath);
 79 
 80     /**
 81      * Apply changes to a file, which is present in src and dst.
 82      *
 83      * @param subPath the relative file path to change
 84      * @throws ScriptException if an {@link IOException} occurs or running a
 85      *                         system command fails
 86      */
 87     void changeFile(Path subPath);
 88 
 89     /**
 90      * Remove a file, which is present in the dst, but not in the src.
 91      *
 92      * @param subPath the relative file path to remove
 93      * @throws ScriptException if an {@link IOException} occurs or running a
 94      *                         system command fails
 95      */
 96     void removeFile(Path subPath);
 97 
 98     /**
 99      * Remove a directory, which is present in the dst, but not in the src.
100      *
101      * @param subPath the relative directory path to remove
102      * @throws ScriptException if an {@link IOException} occurs or running a
103      *                         system command fails
104      */
105     void removeDir(Path subPath);
106   }
107 
108   /**
109    * Implementation for logging purposes.
110    */
111   private class SystemCommandLog implements SystemCommand
112   {
113     private void logPath(String prefix, Path path, AnsiColorEscapeCodes color)
114     {
115       System.out.println(formatAsColored(prefix, color) + path);
116     }
117 
118     @Override
119     public void addDir(Path subPath)
120     {
121       logPath(Op.MKDIR + " : ", subPath, AnsiColorEscapeCodes.CYAN);
122     }
123 
124     @Override
125     public void addFile(Path subPath)
126     {
127       logPath(Op.ADD + "   : ", subPath, AnsiColorEscapeCodes.GREEN);
128     }
129 
130     @Override
131     public void changeFile(Path subPath)
132     {
133       logPath(Op.MOD + "   : ", subPath, AnsiColorEscapeCodes.BLUE);
134     }
135 
136     @Override
137     public void removeFile(Path subPath)
138     {
139       logPath(Op.DEL + "   : ", subPath, AnsiColorEscapeCodes.RED);
140     }
141 
142     @Override
143     public void removeDir(Path subPath)
144     {
145       logPath(Op.RMDIR + " : ", subPath, AnsiColorEscapeCodes.MAGENTA);
146     }
147   }
148 
149   /**
150    * Base class for VCS related implementations containing utility methods for
151    * file operations and for running system commands.
152    */
153   private abstract class SystemCommandVcs implements SystemCommand
154   {
155     /**
156      * Runs a system command. The commands std output goes to stdout. The
157      * commands err output goes to stderr.
158      *
159      * @param args the command line to run in the system
160      * @throws ScriptException if the command returns an error status. The
161      *                         exception message includes the returned error
162      *                         code.
163      */
164     protected void runCommand(String... args)
165     {
166       final int result;
167       try
168       {
169         result = new ProcessBuilder(args).directory(baseDirDst.toFile()).inheritIO().start().waitFor();
170       }
171       catch (IOException | InterruptedException ex)
172       {
173         throw new ScriptException(11, ex);
174       }
175       if (result != 0)
176       {
177         throw new ScriptException(12,
178           "Error code »" + result + "« returned by system command : " + List.of(args));
179       }
180     }
181 
182     /**
183      * Runs a system command and returns its output. The commands err output
184      * goes to stderr.
185      *
186      * @param args the command line to run in the system
187      * @return the command output
188      * @throws ScriptException if the command returns an error status. The
189      *                         exception message includes the returned error
190      *                         code.
191      */
192     protected String readCommandOutput(String... args)
193     {
194       try
195       {
196         final ProcessBuilder builder = new ProcessBuilder(args).directory(baseDirDst.toFile());
197         final Process process = builder.start();
198         process.getErrorStream().transferTo(System.err);
199         try (var s = new ByteArrayOutputStream())
200         {
201           final int result = process.waitFor();
202           if (result != 0)
203           {
204             throw new ScriptException(13,
205               "Error code »" + result + "« returned by system command : " + List.of(args));
206           }
207           process.getInputStream().transferTo(s);
208           return s.toString("UTF-8");
209         }
210       }
211       catch (IOException | InterruptedException ex)
212       {
213         throw new ScriptException(14, ex);
214       }
215     }
216 
217     /**
218      * Checks, whether the given dir or one of its ancestors has a given subdir.
219      *
220      * @param dir    the dir and its parent directories to be checked
221      * @param subDir the subdirectory to search for
222      * @return true, iff the given dir or one of its ancestors has the given
223      *         subdir
224      */
225     private boolean hasParentWithSubDir(Path dir, String subDir)
226     {
227       Path p = dir;
228       while (p != null)
229       {
230         if (Files.isDirectory(p.resolve(subDir)))
231         {
232           return true;
233         }
234         p = p.getParent();
235       }
236       return false;
237     }
238 
239     /**
240      * Returns the VCS specific working copy subdirectory.
241      *
242      * @return the VCS specific working copy subdirectory (e.g.
243      *         <code>".svn"</code> or <code>".git"</code>)
244      */
245     protected abstract String getVcsDirName();
246 
247     /**
248      * Checks, whether the destination directory is under version control.
249      *
250      * @return true, iff the destination directory is under version control
251      */
252     protected boolean isDstDirUnderVersionControl()
253     {
254       return hasParentWithSubDir(baseDirDst, getVcsDirName());
255     }
256 
257     /**
258      * Returns true, iff the VCS working copy is clean.
259      *
260      * @return true, iff the VCS working copy is clean
261      * @throws ScriptException if the check fails
262      */
263     protected abstract boolean isWorkingCopyClean();
264 
265     @Override
266     public void addFile(Path subPath)
267     {
268       try
269       {
270         Files.copy(baseDirSrc.resolve(subPath), baseDirDst.resolve(subPath), COPY_ATTRIBUTES);
271       }
272       catch (IOException ex)
273       {
274         throw new ScriptException(21, ex);
275       }
276     }
277 
278     @Override
279     public void changeFile(Path subPath)
280     {
281       try
282       {
283         Files.copy(baseDirSrc.resolve(subPath), baseDirDst.resolve(subPath), COPY_ATTRIBUTES, REPLACE_EXISTING);
284       }
285       catch (IOException ex)
286       {
287         throw new ScriptException(22, ex);
288       }
289     }
290   }
291 
292   /**
293    * Implementation for the subversion VCS.
294    */
295   private class SystemCommandSvn extends SystemCommandVcs
296   {
297     private static final String DIR_NAME_SVN = ".svn";
298 
299     @Override
300     protected String getVcsDirName()
301     {
302       return DIR_NAME_SVN;
303     }
304 
305     @Override
306     protected boolean isWorkingCopyClean()
307     {
308       return readCommandOutput("svn", "status").isBlank();
309     }
310 
311     @Override
312     public void addDir(Path subPath)
313     {
314       runCommand("svn", "mkdir", subPath.toString());
315     }
316 
317     @Override
318     public void addFile(Path subPath)
319     {
320       super.addFile(subPath);
321       runCommand("svn", "add", subPath.toString());
322     }
323 
324     @Override
325     public void removeFile(Path subPath)
326     {
327       runCommand("svn", "remove", subPath.toString());
328     }
329 
330     @Override
331     public void removeDir(Path subPath)
332     {
333       runCommand("svn", "remove", subPath.toString());
334     }
335   }
336 
337   /**
338    * Implementation for the git VCS.
339    */
340   private class SystemCommandGit extends SystemCommandVcs
341   {
342     private static final String DIR_NAME_GIT = ".git";
343 
344     @Override
345     protected String getVcsDirName()
346     {
347       return DIR_NAME_GIT;
348     }
349 
350     @Override
351     protected boolean isWorkingCopyClean()
352     {
353       return readCommandOutput("git", "status", "--porcelain").isBlank();
354     }
355 
356     @Override
357     public void addDir(Path subPath)
358     {
359       try
360       {
361         Files.createDirectory(baseDirDst.resolve(subPath));
362       }
363       catch (IOException ex)
364       {
365         throw new ScriptException(23, ex);
366       }
367     }
368 
369     @Override
370     public void addFile(Path subPath)
371     {
372       super.addFile(subPath);
373       runCommand("git", "add", subPath.toString());
374     }
375 
376     @Override
377     public void changeFile(Path subPath)
378     {
379       super.changeFile(subPath);
380       runCommand("git", "add", subPath.toString());
381     }
382 
383     @Override
384     public void removeFile(Path subPath)
385     {
386       runCommand("git", "rm", subPath.toString());
387     }
388 
389     @Override
390     public void removeDir(Path subPath)
391     {
392       // git cleans up empty directories itself
393     }
394   }
395 
396   /**
397    * Enum to control colored out.
398    */
399   private enum AnsiColorEscapeCodes
400   {
401     BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE;
402 
403     private int getFgCode(boolean bright)
404     {
405       return ordinal() + (bright ? 90 : 30);
406     }
407 
408     private int getBgCode(boolean bright)
409     {
410       return ordinal() + (bright ? 100 : 40);
411     }
412 
413     private static String formatAsColored(String s,
414       AnsiColorEscapeCodes colorFg, AnsiColorEscapeCodes colorBg, boolean bright)
415     {
416       if (s == null)
417       {
418         return "";
419       }
420       if (colorFg == null && colorBg == null)
421       {
422         return s;
423       }
424       final String CSI = "\u001B[";
425       final String FB = "m";
426       final String RESET = CSI + 0 + FB;
427       final StringBuilder b = new StringBuilder();
428       if (colorFg != null)
429       {
430         b.append(CSI).append(colorFg.getFgCode(bright)).append(FB);
431       }
432       if (colorBg != null)
433       {
434         b.append(CSI).append(colorBg.getBgCode(bright)).append(FB);
435       }
436       b.append(s).append(RESET);
437       return b.toString();
438     }
439   }
440 
441   private static final AnsiColorEscapeCodes COLOR_HEADER = AnsiColorEscapeCodes.YELLOW;
442 
443   /**
444    * ScriptException class providing an error code to return to system.
445    */
446   public static class ScriptException extends RuntimeException
447   {
448     private final int errorCode;
449 
450     private ScriptException(int errorCode, String message)
451     {
452       super(message);
453       this.errorCode = errorCode;
454     }
455 
456     private ScriptException(int errorCode, Throwable cause)
457     {
458       super(cause);
459       this.errorCode = errorCode;
460     }
461 
462     private int getErrorCode()
463     {
464       return errorCode;
465     }
466   }
467 
468   /**
469    * Returns a version of the given String suitable for colored printing.
470    *
471    * @param s       the given String
472    * @param colorFg the foreground color
473    * @return a version of the given String suitable for colored printing to
474    *         System.out
475    */
476   private static String formatAsColored(String s, AnsiColorEscapeCodes colorFg)
477   {
478     return AnsiColorEscapeCodes.formatAsColored(s, colorFg, null, true);
479   }
480 
481   public static void main(String[] args)
482   {
483     try
484     {
485       final var sync = new SynchronizeDirWithVcsWorkingCopy(args);
486       if (sync.checkArgs())
487       {
488         sync.findPaths();
489         sync.applyChanges();
490       }
491     }
492     catch (ScriptException ex)
493     {
494       System.err.println(ex.getLocalizedMessage());
495       System.exit(ex.getErrorCode());
496     }
497     catch (Throwable ex)
498     {
499       System.err.println(ex.getLocalizedMessage());
500       System.exit(99);
501     }
502   }
503 
504   private SynchronizeDirWithVcsWorkingCopy(String... args)
505   {
506     parseArgs(new LinkedList<>(args != null ? List.of(args) : List.of()));
507   }
508 
509   private void parseArgs(Queue<String> listArgs)
510   {
511     if (listArgs.isEmpty())
512     {
513       action = Action.SHOW_HELP;
514     }
515     else
516     {
517       String nextArg = listArgs.poll();
518       while (nextArg != null && nextArg.startsWith("-") && !nextArg.equals("-"))
519       {
520         switch (nextArg)
521         {
522           case "-h", "--help" -> action = Action.SHOW_HELP;
523           case "-d", "--dry-run" -> action = Action.DRY_RUN;
524           case "-s", "--svn" -> action = Action.SVN;
525           case "-g", "--git" -> action = Action.GIT;
526           default ->
527           {
528             action = null;
529             return;
530           }
531         }
532         nextArg = listArgs.poll();
533       }
534       if ("-".equals(nextArg) && !listArgs.isEmpty())
535       {
536         nextArg = listArgs.poll();
537       }
538       if (nextArg != null && listArgs.size() <= 1)
539       {
540         final Path path1 = Paths.get(nextArg);
541         baseDirSrc = Files.isDirectory(path1) ? path1.toAbsolutePath().normalize() : null;
542         nextArg = listArgs.poll();
543         if (nextArg != null)
544         {
545           final Path path2 = Paths.get(nextArg);
546           baseDirDst = Files.isDirectory(path2) ? path2.toAbsolutePath().normalize() : null;
547         }
548         validPaths = baseDirSrc != null && baseDirDst != null;
549       }
550     }
551   }
552 
553   private static void showUsage()
554   {
555     List.of(
556       "usage : " + SynchronizeDirWithVcsWorkingCopy.class.getSimpleName() + " [ -d | -s | -g ] <source-directory> <destination-directory>",
557       "        Replicate the state of a source directory tree to a target directory which is under version control.",
558       "        -h | --help             : show this help",
559       "        -d | --dry-run          : dry run, perform no action, just show info",
560       "        -s | --svn              : run SVN commands",
561       "        -g | --git              : run GIT commands",
562       "        -                       : stop parsing options",
563       "        <source-directory>      : new content",
564       "        <destination-directory> : existing VCS working copy"
565     )
566       .forEach(System.out::println);
567   }
568 
569   /**
570    * Check the given command line arguments.
571    *
572    * @return true to continue running the script, false to show usage help or
573    *         errors
574    * @throws ScriptException if invalid arguments or directories are specified
575    */
576   private boolean checkArgs()
577   {
578     if (action == null)
579     {
580       showUsage();
581       throw new ScriptException(1, "Invalid options specified.");
582     }
583     else if (action.equals(Action.SHOW_HELP))
584     {
585       showUsage();
586       return false;
587     }
588     else if (!validPaths)
589     {
590       if (baseDirSrc == null)
591       {
592         throw new ScriptException(2, "Source directory is invalid!");
593       }
594       if (baseDirDst == null)
595       {
596         throw new ScriptException(3, "Destination directory is invalid!");
597       }
598     }
599     return true;
600   }
601 
602   /**
603    * Collects paths for source and destination.
604    *
605    * @throws ScriptException if an {@link IOException} occurs or running a
606    *                         system command fails
607    */
608   private void findPaths()
609   {
610     systemCommands.add(new SystemCommandLog());
611     final String strMode;
612     final SystemCommandVcs vcs;
613     switch (action)
614     {
615       case DRY_RUN ->
616       {
617         strMode = "DRY-RUN";
618         vcs = null;
619       }
620       case SVN ->
621       {
622         strMode = "RUN SVN";
623         vcs = new SystemCommandSvn();
624       }
625       case GIT ->
626       {
627         strMode = "RUN GIT";
628         vcs = new SystemCommandGit();
629       }
630       default -> throw new ScriptException(28, "Unknown action specified!");
631     }
632     if (vcs != null)
633     {
634       if (!vcs.isDstDirUnderVersionControl())
635       {
636         throw new ScriptException(24, "Destination directory is not under version control!");
637       }
638       if (!vcs.isWorkingCopyClean())
639       {
640         throw new ScriptException(25, "Working copy is not clean! Please commit or revert changes first.");
641       }
642       systemCommands.add(vcs);
643     }
644     System.out.println("Replicate data" + formatAsColored(" => " + strMode, COLOR_HEADER));
645     System.out.println(formatAsColored("=> from source directory", COLOR_HEADER) + " »" + baseDirSrc + "«");
646     System.out.println(formatAsColored("=> to   target directory", COLOR_HEADER) + " »" + baseDirDst + "«");
647     scanDirectoryTree(baseDirSrc, srcDirectories, srcFiles, true);
648     scanDirectoryTree(baseDirDst, dstDirectories, dstFiles, false);
649   }
650 
651   private void scanDirectoryTree(Path startDir, SortedSet<Path> directories,
652     SortedSet<Path> files, boolean excludeEmptyDirs)
653   {
654     /**
655      * A FileVisitor to collect directory and file paths into given collections.
656      * It takes care to ignore VCS directories (like <code>".svn"</code> or
657      * <code>".git"</code>).
658      */
659     final FileVisitor<Path> fileVisitor = new SimpleFileVisitor<>()
660     {
661       private final Collection<Path> excludes = Set.of(
662         startDir.resolve(SystemCommandSvn.DIR_NAME_SVN),
663         startDir.resolve(SystemCommandGit.DIR_NAME_GIT));
664 
665       @Override
666       public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
667         throws IOException
668       {
669         if (dir.equals(startDir))
670         {
671           return CONTINUE;
672         }
673         else if (excludes.contains(dir))
674         {
675           return SKIP_SUBTREE;
676         }
677         else
678         {
679           directories.add(startDir.relativize(dir));
680           return CONTINUE;
681         }
682       }
683 
684       @Override
685       public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
686         throws IOException
687       {
688         files.add(startDir.relativize(file));
689         return CONTINUE;
690       }
691     };
692     try
693     {
694       Files.walkFileTree(startDir, fileVisitor);
695       if (excludeEmptyDirs)
696       {
697         directories.stream()
698           .filter(srcDir -> files.stream().noneMatch(file -> file.startsWith(srcDir)))
699           .forEach(emptySrcDir ->
700           {
701             System.out.print(formatAsColored("=> Ignoring empty source directory :", COLOR_HEADER));
702             System.out.println(" »" + emptySrcDir + "«");
703             srcDirectories.remove(emptySrcDir);
704           });
705       }
706     }
707     catch (IOException ex)
708     {
709       throw new ScriptException(29, ex);
710     }
711   }
712 
713   private class PathAction implements Comparable<PathAction>
714   {
715     private final Path path;
716     private final Op operation;
717 
718     private PathAction(Path path, Op operation)
719     {
720       this.path = path;
721       this.operation = operation;
722     }
723 
724     @Override
725     public int compareTo(PathAction other)
726     {
727       final boolean isThisRmdir = Op.RMDIR.equals(this.operation);
728       final boolean isOtherRmdir = Op.RMDIR.equals(other.operation);
729       if (isThisRmdir)
730       {
731         if (isOtherRmdir)
732         {
733           return -this.path.compareTo(other.path);
734         }
735         else
736         {
737           return 1;
738         }
739       }
740       else
741       {
742         if (isOtherRmdir)
743         {
744           return -1;
745         }
746         else
747         {
748           return this.path.compareTo(other.path);
749         }
750       }
751     }
752 
753     @Override
754     public boolean equals(Object object)
755     {
756       return object instanceof PathAction other ? this.compareTo(other) == 0 : false;
757     }
758 
759     @Override
760     public int hashCode()
761     {
762       return Objects.hash(path, operation);
763     }
764 
765     private void run()
766     {
767       systemCommands.forEach(systemCommand ->
768       {
769         switch (operation)
770         {
771           case MKDIR -> systemCommand.addDir(path);
772           case ADD -> systemCommand.addFile(path);
773           case MOD -> systemCommand.changeFile(path);
774           case DEL -> systemCommand.removeFile(path);
775           case RMDIR -> systemCommand.removeDir(path);
776           default -> throw new ScriptException(98, "Invalid Op: " + operation);
777         }
778       });
779     }
780   }
781 
782   /**
783    * Applies the changes to the destination dir.
784    *
785    * @throws ScriptException if an {@link IOException} occurs or running a
786    *                         system command fails
787    */
788   private void applyChanges()
789   {
790     Stream.of(
791       srcDirectories.stream().filter(not(dstDirectories::contains)).map(dir -> new PathAction(dir, Op.MKDIR)),
792       srcFiles.stream().filter(not(dstFiles::contains)).map(file -> new PathAction(file, Op.ADD)),
793       srcFiles.stream().filter(dstFiles::contains).map(file -> new PathAction(file, Op.MOD)),
794       dstFiles.stream().filter(not(srcFiles::contains)).map(file -> new PathAction(file, Op.DEL)),
795       dstDirectories.stream().filter(not(srcDirectories::contains)).map(dir -> new PathAction(dir, Op.RMDIR)))
796       .flatMap(s -> s)
797       .sorted()
798       .forEachOrdered(PathAction::run);
799   }
800 }