1
2 package shebang.netbeans;
3
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.
32
33
34 @author
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
66 @throws ScriptException{@link IOException}
67
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
75 @throws ScriptException{@link IOException}
76
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
84 @throws ScriptException{@link IOException}
85
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
93 @throws ScriptException{@link IOException}
94
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
102 @throws ScriptException{@link IOException}
103
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.
157
158
159 @param args
160 @throws ScriptException
161
162
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.
184
185
186 @param args
187 @return
188 @throws ScriptException
189
190
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
221 @param subDir
222 @return
223
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
243 <code></code><code></code>
244
245 protected abstract String getVcsDirName();
246
247
248 Checks, whether the destination directory is under version control.
249
250 @return
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
261 @throws ScriptException
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
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
472 @param colorFg
473 @return
474
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
573
574 @throws ScriptException
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{@link IOException}
606
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 <code></code>
657 <code></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{@link IOException}
786
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 }