Welcome, guest | Sign In | My Account | Store | Cart

Utility to add recursive capability to fc.exe on Windows. Will optionally copy/update missing or older files as needed. If you have a diff.exe in your path, say from having installed TkDiff utility, the diff.exe will be used as the prirmary compare tool, with fc.exe as the backup tool.

Tcl, 923 lines
  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
# Recursive file compare utility for Windows.
# Calls diff.exe or fc.exe for each file found.

# Updated by John Brearley  Apr 2017
# email: brearley@bell.net
# email: jrbrearley4@gmail.com

# License: This script is free to use, modify and/or redistribute,
# however you MUST leave the author credit information above as is
# and intact.

# Support: Available on a best effort basis.

# Notes
# 1) For Win10, when you reboot with Repair disk, you cant run TCL from the command prompt.
#    There is something about the Repair DOS environment that does not let even a command line
#    oriented program to run. Same goes for Windows Powershell.
# 2) Need to try on Win7 Repair disk some time...
# 3) Some files are locked, so cant be read, eg c:\Windows\ServiceProfiles\LocalService\NTUSER.DAT
#    and FC gives misleading error about "no such file", even though it is visible.
# 4) regedit.exe is a perennial failure, but manually compares OK with fc.exe.
# 5) Upgrading TCL from 8.63b (Nov 2012) to 8.6.4.1 (Jan 2017) really reduced the number of errors
#    flagged, as the fc.exe retry mechanism shows many files are identical, even if diff.exe does
#    NOT agree. This is the fcr counter counter in the stats summary.


#==================== check_file_name ==================================
# Check if file is a constantly changing Windows or Norton file. These
# files are often locked down from even admin access, and cant be readily
# updated.
#
# Returns: "windows" | "norton" | "other"
#=======================================================================
proc check_file_name {file} {

   # NB: The categories used here MUST be kept in sync with ::cat_list !!!

   # Look for selected Windows or Norton directories.
   # NB: Some of the regexp patterns here rely on having the full file pathname!
   if {[regexp -nocase {^[a-z]:/?(ProgramData|Program\s*Files).*(Microsoft|Windows)} $file]} {
      return "windows"
   } elseif {[regexp -nocase {^[a-z]:/?(Windows|Users.*(Microsoft|Windows))} $file]} {
      return "windows"
   } elseif {[regexp -nocase {(hiberfil|pagefile|swapfile).*sys$} $file]} {
      return "windows"

   } elseif {[regexp -nocase {^[a-z]:/?(ProgramData|Program\s*Files).*Norton} $file]} {
      return "norton"
   } elseif {[regexp -nocase {^[a-z]:/?Users.*/Norton} $file]} {
      return "norton"

   } else {
      # Catchall for everything else.
      return "other"
   }
}


#==================== cleanup ==========================================
# Cleanup exit routine
#=======================================================================
proc cleanup { } {

   # log summary details
   set total_min [expr int(([clock seconds] - $::start_sec)/60)]
   log_info "\n[timestamp] $::self All done, $::error_cnt errors, $::nolog_cnt nolog, $::warning_cnt warn\
      \npattern=$::pattern src_dir=$::src_dir dest_dir=$::dest_dir"

   # Dump formatted stats names as header lines for a table.
   set f1 "%8s" ;# format for category column
   set f2 "%6s" ;# format for stat number column
   set hdr1 [format $f1 "category"]
   set hdr2 [format $f1 "========"]
   foreach i $::stat_list {
      # puts "i=$i"
      set hdr1 "$hdr1 [format $f2 $i]"
      set hdr2 "$hdr2 ======"
   }
   log_info "\n$hdr1\n$hdr2"

   # Dump formatted stats on table with one row per category.
   foreach i $::cat_list {
      set line [format $f1 $i] ;# start line with category name
      foreach j $::stat_list {
         set x $::stats_array($i,$j)
         set line "$line [format $f2 $x]"
         # puts "i=$i j=$j x=$x"
      }
      if {$i == "total"} {
         # Put visual break before total line.
         log_info $hdr2
      }
      log_info $line
   }

   # Other info
   log_info "\n$::subdir_cnt subdirectories, $::hidden_files_cnt hidden_files, $::hidden_subdir_cnt hidden_subdir, copy_opt=$::copy_opt"
   log_info "$::curly_tot_cnt curly_brace_tot, $::curly_matched_pair_cnt matched, $::curly_open_only_cnt open, $::curly_close_only_cnt close, $::curly_error_cnt error"
   log_info "TCL $::tcl_patchLevel, total time $total_min minutes \nsee $::out_file \nsee $::retry_file"

   # Exit with error count.
   exit $::error_cnt
}


#==================== compare_files ====================================
# Compares a specified source file to specified destination file
#
# Returns: OK, FAIL
#=======================================================================
proc compare_files {i src dest no_log category} {

   # If necessary, convert / to \ for optional retry by fc.exe
   set src2 $src
   set dest2 $dest
   # set ::compare_tool "fc.exe" ;# test code
   # First we escape curly-braces
   # if {[regexp {\{.*\}} $src]} {
   #    regsub -all "\{" $src2 "\\\{" src2 ;# Yes, 3 escapes, not 4!
   #    regsub -all "\}" $src2 "\\\}" src2 ;# Yes, 3 escapes, not 4!
   #    regsub -all "\{" $dest2 "\\\{" dest2 ;# Yes, 3 escapes, not 4!
   #    regsub -all "\}" $dest2 "\\\}" dest2 ;# Yes, 3 escapes, not 4!
   # }

   # Now covert / to \ Windows style.
   regsub -all {/} $src2  {\\} src2
   regsub -all {/} $dest2 {\\} dest2

   # Finally, add double-quotes for case of embedded whitespace.
   # No, this makes things worse...
   # if {[regexp {\s} $src2]} {
   #    set src2  "\"$src2\""
   #    set dest2 "\"$dest2\""
   # }
   # log_info "compare_files i=$i src2=$src2 dest2=$dest2"
   
   # Compare the files.
   if {$::compare_tool == "fc.exe"} {
      # fc.exe uses src2 / dest2 with windows path format
      set catch_resp [catch "exec $::compare_tool {$src2} {$dest2}" catch_msg]
   } else {
      # diff.exe uses Unix path format
      set catch_resp [catch "exec $::compare_tool {$src} {$dest}" catch_msg]
   }

   # If files compared OK, we are done.
   # set catch_resp 1 ;# test code
   if {$catch_resp == 0} {
      log_info "compare_files i=$i $dest $::compare_tool compared (OK)" "true"
      return "OK"
   }

   # For Windows & Program directories, compare again using fc.exe.
   # Tried wrapping fc.exe inside .bat file, also run as cmd.exe /c fc.bat, no improvement.
   set no_log [string trim $no_log]
   if {$::compare_tool != "fc.exe" && [regexp -nocase {^[a-z]:/?(Windows|Program)} $src]} {
      # See if fc.exe gives a different result.
      log_info "compare_file retry with fc.exe i=$i src2=$src2 dest2=$dest2 compare_tool=$::compare_tool no_log=$no_log"
      set catch_resp [catch "exec fc.exe {$src2} {$dest2}" catch_msg]
      set catch_msg [truncate_msg $catch_msg] ;# limit the garbage dumped into the log file
      log_info "fc.exe i=$i catch_resp=$catch_resp catch_msg=$catch_msg"
      # set catch_resp 0 ;# test code
      if {$catch_resp == 0} {
         # Always show when fc retry succeeded, increment specific counters for this event.
         log_info "compare_files i=$i $dest2 fc.exe retry compared (OK)"
         incr ::stats_array(total,fcr)
         incr ::stats_array($category,fcr)
         return "OK"
      }
   }

   # Deal with the error. The calling routine will suppress the first compare failure.
   set status [get_status $catch_msg $no_log $category "diff"]
   log_error "compare_files i=$i $dest $::compare_tool failed ($status $category)" $no_log
   # log_info "$catch_msg" ;# shows actual file differences, really floods the log.

   # Add this file to running list in DOS batch file for manual retry later on.
   log_retry $i $src $dest $no_log
   return "FAIL"
}


#==================== copy_file ========================================
# Copies specified source file to specified destination
#
# Returns: OK, FAIL
#=======================================================================
proc copy_file {i src dest dest_dir no_log category} {

   # If copy not requested, return.
   if {$::copy_opt == ""} {
      # Return status FAIL signals the calling routine to move on.
      # There are no stats counters to increment here.
      return "FAIL"
   }

   # If necessary, create the required destination directory.
   # TCL dirname has issue with unmatched close curly-brace, so we use the dest_dir 
   # that was passed to this routine
   # log_info "copy_file i=$i src=$src dest=$dest dest_dir=$dest_dir"
   if {![file isdirectory "$dest_dir"]} {
      set catch_resp [catch "file mkdir {$dest_dir}" catch_msg]
      # set catch_resp 1 ;# test code
      # set catch_msg abcd ;# test code
      if {$catch_resp == 0} {
         # Always log directory creation.
         log_info "copy_file i=$i created destination directory: $dest_dir (OK $category)" 
      } else {
         # Always log the error.
         set status [get_status $catch_msg "" $category]
         log_error "copy_file i=$i could not create dest_dir=$dest_dir, $catch_msg ($status $category)"
         # Increment the appropiate counters.
         incr ::stats_array(total,cpyfl)
         incr ::stats_array($category,cpyfl)
         return "FAIL"
      }

   } else {
      # puts "copy_file i=$i dest_dir=$dest_dir already exists, (OK $category)"
   }

   # Check if the source file is still there. There are a number of temporary folders,
   # such as /Windows/SoftwareDistribution/Download, also some Norton folders, where
   # items may have been found earlier by rec_find, but the OS has quietly purged
   # them by the time we actually get around to checking on them. 
   if {[file exists "$src"]} {
      # puts "copy_file i=$i src=$src still exists, (OK $category)"
   } else {
      # Always log the error.
      log_error "copy_file i=$i src=$src is now missing, (gone $category)"
      incr ::stats_array(total,gone)
      incr ::stats_array($category,gone)
      return "FAIL"
   }

   # Copy the file. File names have largely been vetted for unmatched curly-braces.
   set catch_resp [catch "file copy -force {$src} {$dest}" catch_msg]
   # set catch_resp 1 ;# test code
   # set catch_msg abcd ;# test code
   if {$catch_resp == 0} {
      log_info "copy_file i=$i copied $dest (OK $category)" $no_log
      return "OK"

   } else {
      # Always log the error.
      set status [get_status $catch_msg "" $category]
      log_error "copy_file i=$i could not copy to $dest, $catch_msg ($status $category)"
      incr ::stats_array(total,cpyfl)
      incr ::stats_array($category,cpyfl)
      return "FAIL"
   }
}


#==================== fix_curly ========================================
# Attempts to work around known issues with missing curly brackets in
# path/file names
#
# Returns: string
#=======================================================================
proc fix_curly {path category} {

   # NB: Initially, I had been converting / in pathnames to \ for the benefit
   # of fc.exe. This appears to have created a lot of issues with TCL file copy.
   # When the pathname looked like abc\def\\{1234}..., the open curly-brace looked
   # like it was being escaped, when in reality it was a directory delimiter.

   # NB: extra \ in pathname above is to work around TCL interpreter known issue of
   # unmatched curly braces in comments inside a loop, yes comments inside a loop,
   # choking up!!!

   # Deal with single unmatched curly-brace. This does NOT handle case of a 
   # correctly matched pair of curly-braces followed by an unmatched curly-brace.
   if {[regexp {(\{.*)} $path - x]} {
      # Found open curly-brace
      incr ::curly_tot_cnt
      # log_info "main i=$i file=$file found open curly-brace x=$x"
      # Check for matching close curly-brace AFTER the open curly-brace.
      # This is done by checking $x, which starts with the open curly-brace.
      if {[regexp {\}} $x]} {
         # TCL file copy works with matched curly-braces in the filenames, no need to escape them.
         incr ::curly_matched_pair_cnt
         # log_info "fix_curly found matched pair curly-braces x=$x path=$path category=$category"
      } else {
         incr ::curly_open_only_cnt
         regsub -all "\{" $path "\\\{" path ;# Yes, 3 escapes, not 4!
         # log_info "fix_curly escaped unmatched open curly-brace path=$path category=$category"
      }

   } elseif {[regexp {\}} $path]} {
      # There is no open curly-brace, but we found an unmatched close curly-brace
      incr ::curly_tot_cnt
      incr ::curly_close_only_cnt
      regsub -all "\}" $path "\\\}" path ;# Yes, 3 escapes, not 4!
      # log_info "fix_curly escaped unmatched close curly-brace path=$path category=$category"
   }

   # Return modified path
   return $path
}


#==================== get_status =======================================
# Parses the error msg, determines the appropriate status code and
# increments the appropriate category counter.
#
# Returns: string
#=======================================================================
proc get_status {msg no_log category {tool ""}} {

   # Look for selected error messages.
   set status ""
   set no_log [string trim $no_log]
   set tool [string trim $tool]
   # For tool=diff, use different counters.
   if {$tool == "diff"} {
      set status "diff"
      if {$no_log == ""} {
         incr ::stats_array(total,diff)
         incr ::stats_array($category,diff)
      }

   } elseif {[regexp -nocase "permission.*denied" $msg]} {
      set status "noacc"
      if {$no_log == ""} {
         incr ::stats_array($category,noacc)
         incr ::stats_array(total,noacc)
      }

   } elseif {[regexp -nocase "brace" $msg]} {
      set status "curly"
      if {$no_log == ""} {
         incr ::curly_error_cnt
         incr ::stats_array($category,curly)
         incr ::stats_array(total,curly)
      }

   } else {
      # Catch all bucket. 
      set status "unexp"
      if {$no_log == ""} {
         incr ::stats_array($category,unexp)
         incr ::stats_array(total,unexp)
      }
   }
   return $status
}


#==================== log_error ========================================
# Displays error message right now, adds message to the log file,
# increments error counter.
#
# Optional no_log parameter can suppress messages in log_file. This
# allows you to stop flooding the log_file for known conditions.
#=======================================================================
proc log_error {msg {no_log ""}} {

   # Prepend ERROR: & hand off to log_info
   set msg "ERROR: $msg"
   set no_log [string trim $no_log]
   if {$no_log == ""} {
      incr ::error_cnt
   }
   log_info $msg $no_log

   # Test code
   # if {$::error_cnt >= 100} {
   #    puts "\n\nToo many errors, stopping!"
   #    cleanup
   # }
}


#==================== log_info =========================================
# Displays info message right now, adds message to the log file.
#
# Optional no_log parameter can suppress messages in log_file. This
# allows you to stop flooding the log_file for known conditions.
#=======================================================================
proc log_info {msg {no_log ""}} {

   # Always display msg. Add "not logged" as appropriate.
   set no_log [string trim $no_log]
   if {$no_log != ""} {
      set msg "$msg (NL)"
      incr ::nolog_cnt
   }
   puts "$msg"

   # Append msg to running log file, when it is available for use.
   # no_log allows for suppressing repeated messages that flood the
   # log_file and make it unreadable.
   if {$::out != "" && $no_log == ""} {
      puts $::out "$msg"
      flush $::out
   }
}


#==================== log_retry ========================================
# Appends data to retry_cfc.bat file so user can easily manually retry
# failed file compare operations.
#=======================================================================
proc log_retry {i src dest no_log} {

   # For no_log not null, we are done
   set no_log [string trim $no_log]
   if {$no_log != ""} {
      return
   }

   # Now covert / to \ Windows style.
   regsub -all {/} $src  {\\} src
   regsub -all {/} $dest {\\} dest

   # Add this file to running list in DOS batch file for manual retry later on.
   puts $::retry "echo i=$i $src"
   puts $::retry "fc.exe \"$src\" \"$dest\""
   puts $::retry "echo i=$i $src"
   puts $::retry "if '%tr%' == '1' pause"
   flush $::retry
}

#==================== log_warning ======================================
# Appends specified WARNING msg to log file, displays msg on terminal.
#
# Optional no_log parameter can suppress messages in log_file. This
# allows you to stop flooding the log_file for known conditions.
#=======================================================================
proc log_warning {msg {no_log ""}} {

   # Prepend WARNING: & handoff to log_info
   set msg "WARNING: $msg"
   set no_log [string trim $no_log]
   if {$no_log == ""} {
      incr ::warning_cnt
   }
   log_info $msg $no_log
}


#==================== rec_find =========================================
# Takes list of file in the specified directory and adds them to the 
# global file list. Calls itself recursively for any subdirectories found.
#=======================================================================
proc rec_find {cur_dir} {

   # If no directories specified, we are done.
   set cur_dir [string trim $cur_dir]
   # puts "rec_find start cur_dir=$cur_dir"
   if {$cur_dir == ""} {
      return
   }

   # More recent versions of TCL 8.6+ seem to need a trailing / on the directories.
   # If not, rec_find will mess up and only get the subdirectories of pwd.
   # Also need trailing / for case of whole mounted subdirectory.
   # The setup routine ensures a single trailing / is present.

   # Get the category for this directory.
   set category [check_file_name $cur_dir]
   # puts "rec_find start cur_dir=$cur_dir category=$category"

   # Attempt to fix up known curly brace issues.
   # It turns out that glob cant handle directory name with unmatched curly brace either.
   # We allow the error to occur in order to make it more visible to the user.
   # So any files in a directory with unmatched curly braces are NOT listed and processed.
   # set cur_dir [fix_curly $cur_dir $category] ;# turned off so errors are NOT hidden!!!

   # Find all files in the current directory that match the specified pattern.
   # NB: You need to explicitly ask for hidden files as a separate glob request!
   # NB: Some directories start with $ (eg: $Recycle.Bin) which can make the TCL
   #     interpreter think this is a variable. So put { } around $cur_dir to fix.
   set current_files ""
   set status ""
   # log_info "rec_find(1) calling glob cur_dir=$cur_dir"
   set catch_resp [catch "set current_files \[glob -nocomplain -type f -directory {$cur_dir} $::pattern\]" catch_msg]
   # log_info "rec_find(1) return glob cur_dir=$cur_dir catch_resp=$catch_resp catch_msg=$catch_msg category=$category"
   if {$catch_resp != 0} {
      set  status [get_status $catch_msg "" $category]
      log_error "rec_find(1) $cur_dir $catch_msg ($status $category)"
   }
   set cnt_cf [llength $current_files]

   # Testing has shown that when you cant access a directory list of files due to
   # permission access issues or curly brace issues, you wont be able to get hidden file 
   # or directories either. So there is no point continuing onwards, getting the same 
   # error repeatedly.
   if {$status == "noacc" || $status == "curly"} {
      return
   }

   # Now get hidden files
   set hidden_files ""
   set catch_resp [catch "set hidden_files  \[glob -nocomplain -type {f hidden} -directory {$cur_dir} $::pattern\]" catch_msg]
   if {$catch_resp != 0} {
      set  status [get_status $catch_msg "" $category]
      log_error "rec_find(2) $cur_dir $catch_msg ($status $category)"
   }
   set cnt_hf [llength $hidden_files]
   incr ::hidden_files_cnt $cnt_hf
   # if {$hidden_files != ""} {
   #    log_info "\nrec_find cur_dir=$cur_dir \ncnt=$cnt_cf current_files=$current_files \ncnt=$cnt_hf hidden_files=$hidden_files"
   # }

   # Get sorted unique list of all files
   set sorted_files [lsort -unique "$current_files $hidden_files"]
   set cnt_sf [llength $sorted_files]
   # log_info "rec_find cur_dir=$cur_dir cnt_sf=$cnt_sf"

   # Save each file as a seperate element in file_array. This avoids issues
   # with long lists and mismatched curly braces for filenames that have
   # embedded spaces or curly braces in them
   foreach file $sorted_files {
      set cnt_tot $::stats_array(total,found)
      incr cnt_tot
      set ::stats_array(total,found) $cnt_tot
      set ::file_array($cnt_tot) $file ;# NB: This is the full file pathname!
      if {$cnt_tot % 1000 == 0} {
         log_info "rec_find i=$cnt_tot file=$file ($category)" "true"
      }
      # log_info "rec_find i=$cnt_tot file=$file ($category)" "true"
      set category [check_file_name $file]
      incr ::stats_array($category,found)
   }

   # Find all subdirectories at this level. 
   set current_subdir ""
   set catch_resp [catch "set current_subdir \[glob -nocomplain -type d -directory {$cur_dir} *\]" catch_msg]
   if {$catch_resp != 0} {
      set  status [get_status $catch_msg "" $category]
      log_error "rec_find(3) $cur_dir $catch_msg ($status $category)"
   }
   set cnt_csd [llength $current_subdir]

   # You need to explicitly ask for hidden directories
   set hidden_subdir ""
   set catch_resp [catch "set hidden_subdir \[glob -nocomplain -type {d hidden} -directory {$cur_dir} *\]" catch_msg]
   if {$catch_resp != 0} {
      set  status [get_status $catch_msg "" $category]
      log_error "rec_find(4) $cur_dir $catch_msg ($status $category)"
   }
   set cnt_hsd [llength $hidden_subdir]
   incr ::hidden_subdir_cnt $cnt_hsd
   # if {$hidden_subdir != ""} {
   #    log_info "\nrec_find cur_dir=$cur_dir \ncnt=$cnt_csd current_subdir=$current_subdir \ncnt=$cnt_hsd hidden_subdir=$hidden_subdir"
   # }

   # Get sorted unique list of all subdirectories
   set sorted_subdir [lsort -unique "$current_subdir $hidden_subdir"]
   set cnt_ssd [llength $sorted_subdir]
   # log_info "rec_find cur_dir=$cur_dir cnt_ssd=$cnt_ssd"

   # Call ourselves recursively for each subdirectory.
   foreach dir $sorted_subdir {
      incr ::subdir_cnt
      # puts "calling rec_find cur_dir=$cur_dir next subdir dir=$dir"
      rec_find "$dir"
   }
}


#==================== setup ============================================
# Does basic initialization, gives online help if needed.
#=======================================================================
proc setup { } {

   # Initialize global counters
   set ::curly_close_only_cnt 0
   set ::curly_error_cnt 0
   set ::curly_matched_pair_cnt 0
   set ::curly_open_only_cnt 0
   set ::curly_tot_cnt 0
   set ::copy_opt "" ;# null value used to control optional logging of events.
   set ::error_cnt 0
   set ::hidden_files_cnt 0
   set ::hidden_subdir_cnt 0
   set ::nolog_cnt 0
   set ::out "" ;# log file is not available yet
   set ::subdir_cnt 0
   set ::warning_cnt 0

   # Set up global stats array, indexed by category & statistic name.
   # NB: The order of the items in these lists determines the data table 
   # order of the rows and columns displayed by the exit cleanup routine.
   set ::cat_list "windows norton other total"
   set ::stat_list "noacc found miss diff repr cpyfl gone curly unexp fcr"
   foreach i $::cat_list {
      foreach j $::stat_list {
         set ::stats_array($i,$j) 0
         set x $::stats_array($i,$j)
         # puts "stats_array i=$i j=$j x=$x"
      }
   }

   # Give online help if necessary
   set ::self [file tail $::argv0]
   set x [lindex $::argv 0]
   set x [string tolower $x]
   set x [string range $x 0 1]
   if {$x == "-h" || $x == "/?"} {
      puts "Basic Usage: tclsh $::self \[pattern\] \[src_dir\] \[dest_dir\] <-copy>"
      puts " "
      puts "Utility recursively gets list of all files in the src_dir"
      puts "directory that match the specified \[pattern\]. Files are compared" 
      puts "to same file name in the \[dest_dir\] using diff.exe or fc.exe."
      puts " "
      puts "Use pattern \"*\" to compare all files."
      puts "Use pattern \"*.jpeg\" to compare only .jpg files."
      puts " " 
      puts "Optional <-copy> will copy missing or overwrite different files in"
      puts "the \[destination_directory\]." 
      exit 1
   }

   # Save start time, in seconds.
   set ::start_sec [clock seconds]

   # Check for 3 required command line tokens
   if {$::argc < 3} {
      log_error "Must specify minimum 3 command line tokens! \nFor more info, type: tclsh $::self -h"
      exit 1
   }

   # Get 3 required command line parms.
   regsub -all {\\} $::argv {/} ::argv ;# convert \ for windows filesystem to / 
   set ::pattern [lindex $::argv 0]
   set ::src_dir [lindex $::argv 1]
   if {[regexp {^\.} $::src_dir]} {
      regsub {^\.} $::src_dir [pwd] ::src_dir ;# convert relative path to full path
   }
   set ::dest_dir [lindex $::argv 2]
   if {[regexp {^\.} $::dest_dir]} {
      regsub {^\.} $::dest_dir [pwd] ::dest_dir ;# convert relative path to full path
   }

   # More recent versions of TCL 8.6+ seem to need a single trailing / on the directories.
   # If not, rec_find calls to glob will mess up and only get the subdirectories of pwd.
   # Also need trailing / for case of whole mounted subdirectory.
   set ::src_dir "${::src_dir}/"
   set ::dest_dir "${::dest_dir}/"

   # Convert multiple repeated / in directories to a single /.
   # NB: The extra / royally messed up destination file path computations made in main program loop.
   #     The leading charactar of each directory got lost, causing each directory to be recopied with
   #     the wrong name, filled up the destination disk.
   # NB: For the case of temporary network mounts in the form of //host/directory, we MUST preserve 
   #     the leading //. This is why we start the regsub pattern matching after the first 2 characters!
   regsub -start 2 -all {/+} $::src_dir {/} ::src_dir
   regsub -start 2 -all {/+} $::dest_dir {/} ::dest_dir

   # Needed in main program loop for computing correct destination directory
   set ::src_dir_len [string length $::src_dir]

   # Check for optional -copy token.
   if {$::argc >= 4} {
      set y [lindex $::argv 3]
      set y [string tolower $y]
      if {$y == "-copy"} {
         set ::copy_opt "true"
      } else {
         log_error "Invalid option: $y ! \nFor more info, type: tclsh $::self -h"
         exit 1
      }
   }

   # Choose directory for log file & retry batch file based on known options of different OS.
   # NB: Windows admin user usually doesnt have HOMEPATH defined, but does have USERPROFILE.
   if {[info exists ::env(HOMEPATH)]} {
      set out_path $::env(HOMEPATH) ;# Windows first choice
   } elseif {[info exists ::env(USERPROFILE)]} {
      set out_path $::env(USERPROFILE) ;# Windows second choice, often needed by admin
   } elseif {[info exists ::env(HOME)]} { 
      set out_path $::env(HOME) ;# Linux default
   } else {
      set out_path "[pwd]"
   }
   regsub -all {\\} $out_path {/} out_path ;# convert \ for windows filesystem to /

   # Choose log file name.
   set log_fn [file root $::self]
   set ::out_file "$out_path/$log_fn.log"

   # Open running log file for append.
   set catch_resp [catch "set ::out \[open \"$::out_file\" a\]" catch_msg]
   if {$catch_resp != 0} {
      log_error "Could not open $::out_file catch_msg=$catch_msg"
      exit 1
   }
   # puts "out_path=$out_path out_file=$::out_file out=$::out"

   # Choose error retry DOS batch file name.
   set retry_fn "retry_fc"
   set ::retry_file "$out_path/$retry_fn.bat"

   # Open running error retry DOS batch file for append.
   set catch_resp [catch "set ::retry \[open \"$::retry_file\" a\]" catch_msg]
   if {$catch_resp != 0} {
      log_error "Could not open $::retry_file catch_msg=$catch_msg"
      exit 1
   }
   # puts "out_path=$out_path retry_file=$::retry_file retry=$::retry"

   # Log start date/time, calling parms.
   set ts [timestamp]
   log_info "\n$ts $::self starting pattern=$::pattern\
      \nsrc_dir=$::src_dir dest_dir=$::dest_dir copy_opt=$::copy_opt\
      \nsee: $::out_file \nsee$::retry_file"

   # Add header to retry DOS batch file.
   puts $::retry "\n\n@echo off\necho.\necho.\necho $ts issues from $::self"
   flush $::retry

   # Check source directory exists.
   if {![file isdirectory "$::src_dir"]} {
      log_error "src_dir=$::src_dir not found!"
      exit 1
   }

   # Check destination directory exists.
   if {![file isdirectory "$::dest_dir"]} {
      log_error "dest_dir=$::dest_dir not found!"
      exit 1
   }

   # Are src_dir & dest_dir the same? We allow this for self-test, but warn user.
   if {[string tolower $::src_dir] == [string tolower $::dest_dir]} {
      log_warning "$::self setup src_dir EQ dest_dir $::src_dir !"
      log_warning "Hopefully you are doing a self-test here?"
      log_warning "All files MUST be identical in this case!"
   }

   # Choose compare_tool. If tkdiff suite is not available, then use fc.exe.
   set ::compare_tool "diff.exe"
   set catch_resp [catch "exec $::compare_tool --help" catch_msg]
   if {$catch_resp == 0} {
      # diff.exe give rc=0 on help msg.
      log_info "$::compare_tool is present (OK)"
   } else {
      # Look at error msg. If diff.exe not found, switch to fc.exe.
      if {[regexp "couldn.t.*execute.*no.*such.*file" $catch_msg]} {
         log_info "$::compare_tool does not appear to be installed: $catch_msg"
         set ::compare_tool "fc.exe"
         log_warning "Default tool $::compare_tool is known to lockup on large runs"
      } else {
         log_error "$::compare_tool unexpected response: $catch_msg"
      }
   }
}


#==================== timestamp ========================================
# Returns current date & time formatted as: YYYY/MM/DD HH:MM
#=======================================================================
proc timestamp { } {

   return [clock format [clock seconds] -format "%Y/%m/%d %H:%M"]
}


#==================== truncate_msg =====================================
# Truncates a msg string to limit how much garbage gets dumped into the
# log file.
# 
# Returns: string
#=======================================================================
proc truncate_msg {msg} {

   # Define max length of msg to return.
   set max_len 300

   # Get length of msg.
   set len [string length $msg]

   # Do we need to truncate the msg?
   if {$len <= $max_len} {
      return $msg
   } else {
      set len $max_len
      set msg [string range $msg 0 $len]
      # log_info "truncate_msg len=$len msg=$msg"
      return $msg
   }
}


#==================== Main program =====================================
# Main program
#=======================================================================
# Initialization, give help if needed.
setup

# Test code
# log_info "copied file afdfsdf"
# log_info "should not be logged" "no_log"
# log_error "fsdf sf sf sf sd "
# log_error "error should not be logged" "no_log"
# log_warning "test warn"
# log_warning "warning should not be logged" "no_log"
# cleanup

# Recursively get all files that match the desired pattern.
rec_find "$src_dir"
log_info "[timestamp] main found $::stats_array(total,found) files, $subdir_cnt subdirectories,\
   $error_cnt errors, $::stats_array(total,noacc) noacc \nsee $out_file \nsee$::retry_file"

# Compare source file with corresponding file in destination_directory.
# When copy_opt=true, copy missing files, recopy different files and compare again.
for {set i 1} {$i <= $stats_array(total,found)} {incr i} {

   # Get the source file full path name & src_subdir.
   set src_path $file_array($i) ;# NB: file full source path name is in file_array!
   set src_subdir [file dirname "$src_path"]
   if {![regexp {/$} $src_subdir]} {
      set src_subdir "${src_subdir}/" ;# add trailing / if missing
   }
   set src_file [file tail $src_path]

   # Check if this file is one of the constantly changing Windows or Norton files.
   # This category is used to classify error messages with separate statistic counts,
   # resulting in a much more comprehensible log.
   set category [check_file_name $src_path]
   set category [string trim $category]

   # TCL dirname has issue with unmatched escaped close curly-brace, so save dest_subdir here
   # before we escape the curly-braces.
   # regsub messes up when directory names have embedded spaces
   # set dest_subdir ""
   # regsub "$src_dir" "$src_subdir" "$dest_dir" dest_subdir ;# doesnt always work!
   # puts "regsub: dest_subdir=$dest_subdir"

   # So we use string range to correctly get dest_subdir
   set dest_subdir [string range $src_subdir $src_dir_len end]
   set dest_subdir "${dest_dir}${dest_subdir}" ;# dont need to insert /
   
   # Set the destination file full path name.
   set dest_path "${dest_subdir}${src_file}" ;# dont need to insert /

   # Attempt to fix up known curly brace issues.
   set src_path [fix_curly $src_path $category]
   set dest_path [fix_curly $dest_path $category]
   # log_info "main i=$i src_path=$src_path src_subdir=$src_subdir src_file=$src_file \
   #    dest_subdir=$dest_subdir dest_path=$dest_path category=$category"

   # Watch for src_path EQ dest_path. Error vs Warning response depends on 
   # what user specified for src_dir & dest_dir. We keep processing this
   # file, regardless.
   # set dest_path $src_path ;# test code
   if {[string tolower $src_path] == [string tolower $dest_path]} {
      if {[string tolower $src_dir] == [string tolower $dest_dir]} {
         log_warning "main i=$i src_path EQ dest_path $src_path, src_dir EQ dest_dir $src_dir, presumably this is a self-test, all files MUST be identical!"
      } else {
         log_error "main i=$i src_path EQ dest_path $src_path, BUT src_dir=$src_dir NE dest_dir=$dest_dir, something went seriously wrong! (unexp $category)"
         incr stats_array(total,unexp)
         incr ::stats_array($category,unexp)
      }
   }

   # Check if destination file exists, copy if requested.
   set repair "no"
   if {[file exists "$dest_path"]} {
       log_info "main i=$i $dest_path found (OK $category)" "true"
   } else {
      log_error "main i=$i dest_path=$dest_path NOT found (missing $category)" $copy_opt
      # Always count missing files. These stats help explain what we really did.
      incr stats_array(total,miss)
      incr stats_array($category,miss)
      
      # If requested, copy the file. File copy success is NOT logged, as
      # we will keep processing this file. However, file copy error is always
      # logged, as this will be the last thing we do for this file.
      set rc [copy_file $i "$src_path" "$dest_path" "$dest_subdir" $copy_opt $category]
      if {$rc == "OK"} {
         # File was successfully copied.
         set repair "yes" ;# shows we are attempting to repair file
      } else {
         # If copy_opt was null, then we did NOT try to copy the file, and
         # we move on to the next file, perfectly normal occurance. 
         # If copy_opt was true, we tried and failed to copy this file. 
         # The copy error is logged, and we move on to the next file.
         continue
      }
   }

   # Compare src & dest files. The first compare error can be suppressed
   # when copy_opt is true.  
   set rc [compare_files $i "$src_path" "$dest_path" $copy_opt $category]
   if {$rc == "OK"} {
      # Are we trying to repair this file?
      if {$repair == "yes"} {
         # Repairs are complete. 
         incr stats_array(total,repr)
         incr stats_array($category,repr)
         log_info "main i=$i repair(1) succeeded $dest_path (OK $category)"
       }
       # Processing this file is all done.
       continue

   } else {
      # Files are different. Do we copy and compare again?
      if {$::copy_opt != ""} {
         set rc [copy_file $i "$src_path" "$dest_path" "$dest_subdir" $copy_opt $category]
         if {$rc != "OK"} {
            # We tried and failed to copy this file. Copy error will always be logged.
            # First compare failed, so add this file to running list in DOS batch file for manual retry later on.
            log_retry $i $src_path $dest_path ""
            continue
         }
         # One last try at comparing these files. At this point, we always log
         # the compare errors.
         set rc [compare_files $i "$src_path" "$dest_path" "" $category]
         if {$rc == "OK"} {
            incr stats_array(total,repr)
            incr stats_array($category,repr)
            log_info "main i=$i repair(2) succeeded $dest_path (OK $category)"
         }
      }
   }
}

# Cleanup routine
cleanup

Useful for comparing all files or files that match a specific pattern in 2 separate directory structures. The directory structures do not need a common root structure. You simply specify the start points and the script will sort out the details.

If you try to compare entire disks, including the windows OS, the script will soldier on, but take many hours and get numerous permission denied access errors, even when running with admin privileges.

fc.exe will occasionally hang on some really large files over 1GByte. If you see the script stuck on a particular file for a long time, use task manager to kill just fc.exe and the script willl carry on.

The diff.exe from TkDiff tool suite will be used as the primary compare tool, if it is found in your path. While diff.exe is often faster and more reliable than fc.exe, it regularly declares files to be different that fc.exe says are identical. So the compromise was to always retry the specific file comparison with fc.exe when diff.exe declares an issue. If fc.exe says its identical, then the diff.exe result is ignored.