[DEPRECATED] Go implementation of plcbundle

cmd diff

Changed files
+803 -469
cmd
plcbundle
-467
cmd/plcbundle/commands/compare.go
··· 1 - package commands 2 - 3 - import ( 4 - "fmt" 5 - "io" 6 - "net/http" 7 - "os" 8 - "path/filepath" 9 - "sort" 10 - "strings" 11 - "time" 12 - 13 - flag "github.com/spf13/pflag" 14 - 15 - "github.com/goccy/go-json" 16 - "tangled.org/atscan.net/plcbundle/internal/bundleindex" 17 - ) 18 - 19 - // CompareCommand handles the compare subcommand 20 - func CompareCommand(args []string) error { 21 - fs := flag.NewFlagSet("compare", flag.ExitOnError) 22 - verbose := fs.Bool("v", false, "verbose output (show all differences)") 23 - fetchMissing := fs.Bool("fetch-missing", false, "fetch missing bundles from target") 24 - 25 - if err := fs.Parse(args); err != nil { 26 - return err 27 - } 28 - 29 - if fs.NArg() < 1 { 30 - return fmt.Errorf("usage: plcbundle compare <target> [options]\n" + 31 - " target: URL or path to remote plcbundle server/index\n\n" + 32 - "Examples:\n" + 33 - " plcbundle compare https://plc.example.com\n" + 34 - " plcbundle compare https://plc.example.com/index.json\n" + 35 - " plcbundle compare /path/to/plc_bundles.json\n" + 36 - " plcbundle compare https://plc.example.com --fetch-missing") 37 - } 38 - 39 - target := fs.Arg(0) 40 - 41 - mgr, dir, err := getManager("") 42 - if err != nil { 43 - return err 44 - } 45 - defer mgr.Close() 46 - 47 - fmt.Printf("Comparing: %s\n", dir) 48 - fmt.Printf(" Against: %s\n\n", target) 49 - 50 - // Load local index 51 - localIndex := mgr.GetIndex() 52 - 53 - // Load target index 54 - fmt.Printf("Loading target index...\n") 55 - targetIndex, err := loadTargetIndex(target) 56 - if err != nil { 57 - return fmt.Errorf("error loading target index: %w", err) 58 - } 59 - 60 - // Perform comparison 61 - comparison := compareIndexes(localIndex, targetIndex) 62 - 63 - // Display results 64 - displayComparison(comparison, *verbose) 65 - 66 - // Fetch missing bundles if requested 67 - if *fetchMissing && len(comparison.MissingBundles) > 0 { 68 - fmt.Printf("\n") 69 - if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { 70 - return fmt.Errorf("--fetch-missing only works with remote URLs") 71 - } 72 - 73 - baseURL := strings.TrimSuffix(target, "/index.json") 74 - baseURL = strings.TrimSuffix(baseURL, "/plc_bundles.json") 75 - 76 - fmt.Printf("Fetching %d missing bundles...\n\n", len(comparison.MissingBundles)) 77 - if err := fetchMissingBundles(mgr, baseURL, comparison.MissingBundles); err != nil { 78 - return err 79 - } 80 - } 81 - 82 - if comparison.HasDifferences() { 83 - return fmt.Errorf("indexes have differences") 84 - } 85 - 86 - return nil 87 - } 88 - 89 - func loadTargetIndex(target string) (*bundleindex.Index, error) { 90 - if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { 91 - return loadIndexFromURL(target) 92 - } 93 - return bundleindex.LoadIndex(target) 94 - } 95 - 96 - func loadIndexFromURL(url string) (*bundleindex.Index, error) { 97 - if !strings.HasSuffix(url, ".json") { 98 - url = strings.TrimSuffix(url, "/") + "/index.json" 99 - } 100 - 101 - client := &http.Client{Timeout: 30 * time.Second} 102 - 103 - resp, err := client.Get(url) 104 - if err != nil { 105 - return nil, fmt.Errorf("failed to download: %w", err) 106 - } 107 - defer resp.Body.Close() 108 - 109 - if resp.StatusCode != http.StatusOK { 110 - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 111 - } 112 - 113 - data, err := io.ReadAll(resp.Body) 114 - if err != nil { 115 - return nil, fmt.Errorf("failed to read response: %w", err) 116 - } 117 - 118 - var idx bundleindex.Index 119 - if err := json.Unmarshal(data, &idx); err != nil { 120 - return nil, fmt.Errorf("failed to parse index: %w", err) 121 - } 122 - 123 - return &idx, nil 124 - } 125 - 126 - func compareIndexes(local, target *bundleindex.Index) *IndexComparison { 127 - localBundles := local.GetBundles() 128 - targetBundles := target.GetBundles() 129 - 130 - localMap := make(map[int]*bundleindex.BundleMetadata) 131 - targetMap := make(map[int]*bundleindex.BundleMetadata) 132 - 133 - for _, b := range localBundles { 134 - localMap[b.BundleNumber] = b 135 - } 136 - for _, b := range targetBundles { 137 - targetMap[b.BundleNumber] = b 138 - } 139 - 140 - comparison := &IndexComparison{ 141 - LocalCount: len(localBundles), 142 - TargetCount: len(targetBundles), 143 - MissingBundles: make([]int, 0), 144 - ExtraBundles: make([]int, 0), 145 - HashMismatches: make([]HashMismatch, 0), 146 - ContentMismatches: make([]HashMismatch, 0), 147 - } 148 - 149 - // Get ranges 150 - if len(localBundles) > 0 { 151 - comparison.LocalRange = [2]int{localBundles[0].BundleNumber, localBundles[len(localBundles)-1].BundleNumber} 152 - comparison.LocalUpdated = local.UpdatedAt 153 - comparison.LocalTotalSize = local.TotalSize 154 - } 155 - 156 - if len(targetBundles) > 0 { 157 - comparison.TargetRange = [2]int{targetBundles[0].BundleNumber, targetBundles[len(targetBundles)-1].BundleNumber} 158 - comparison.TargetUpdated = target.UpdatedAt 159 - comparison.TargetTotalSize = target.TotalSize 160 - } 161 - 162 - // Find missing bundles 163 - for bundleNum := range targetMap { 164 - if _, exists := localMap[bundleNum]; !exists { 165 - comparison.MissingBundles = append(comparison.MissingBundles, bundleNum) 166 - } 167 - } 168 - sort.Ints(comparison.MissingBundles) 169 - 170 - // Find extra bundles 171 - for bundleNum := range localMap { 172 - if _, exists := targetMap[bundleNum]; !exists { 173 - comparison.ExtraBundles = append(comparison.ExtraBundles, bundleNum) 174 - } 175 - } 176 - sort.Ints(comparison.ExtraBundles) 177 - 178 - // Compare hashes 179 - for bundleNum, localMeta := range localMap { 180 - if targetMeta, exists := targetMap[bundleNum]; exists { 181 - comparison.CommonCount++ 182 - 183 - chainMismatch := localMeta.Hash != targetMeta.Hash 184 - contentMismatch := localMeta.ContentHash != targetMeta.ContentHash 185 - 186 - if chainMismatch || contentMismatch { 187 - mismatch := HashMismatch{ 188 - BundleNumber: bundleNum, 189 - LocalHash: localMeta.Hash, 190 - TargetHash: targetMeta.Hash, 191 - LocalContentHash: localMeta.ContentHash, 192 - TargetContentHash: targetMeta.ContentHash, 193 - } 194 - 195 - if chainMismatch { 196 - comparison.HashMismatches = append(comparison.HashMismatches, mismatch) 197 - } 198 - if contentMismatch && !chainMismatch { 199 - comparison.ContentMismatches = append(comparison.ContentMismatches, mismatch) 200 - } 201 - } 202 - } 203 - } 204 - 205 - return comparison 206 - } 207 - 208 - func displayComparison(c *IndexComparison, verbose bool) { 209 - fmt.Printf("Comparison Results\n") 210 - fmt.Printf("══════════════════\n\n") 211 - 212 - fmt.Printf("Summary\n───────\n") 213 - fmt.Printf(" Local bundles: %d\n", c.LocalCount) 214 - fmt.Printf(" Target bundles: %d\n", c.TargetCount) 215 - fmt.Printf(" Common bundles: %d\n", c.CommonCount) 216 - fmt.Printf(" Missing bundles: %s\n", formatCount(len(c.MissingBundles))) 217 - fmt.Printf(" Extra bundles: %s\n", formatCount(len(c.ExtraBundles))) 218 - fmt.Printf(" Hash mismatches: %s\n", formatCountCritical(len(c.HashMismatches))) 219 - fmt.Printf(" Content mismatches: %s\n", formatCount(len(c.ContentMismatches))) 220 - 221 - if c.LocalCount > 0 { 222 - fmt.Printf("\n Local range: %06d - %06d\n", c.LocalRange[0], c.LocalRange[1]) 223 - fmt.Printf(" Local size: %.2f MB\n", float64(c.LocalTotalSize)/(1024*1024)) 224 - fmt.Printf(" Local updated: %s\n", c.LocalUpdated.Format("2006-01-02 15:04:05")) 225 - } 226 - 227 - if c.TargetCount > 0 { 228 - fmt.Printf("\n Target range: %06d - %06d\n", c.TargetRange[0], c.TargetRange[1]) 229 - fmt.Printf(" Target size: %.2f MB\n", float64(c.TargetTotalSize)/(1024*1024)) 230 - fmt.Printf(" Target updated: %s\n", c.TargetUpdated.Format("2006-01-02 15:04:05")) 231 - } 232 - 233 - // Show differences 234 - if len(c.HashMismatches) > 0 { 235 - showHashMismatches(c.HashMismatches, verbose) 236 - } 237 - 238 - if len(c.MissingBundles) > 0 { 239 - showMissingBundles(c.MissingBundles, verbose) 240 - } 241 - 242 - if len(c.ExtraBundles) > 0 { 243 - showExtraBundles(c.ExtraBundles, verbose) 244 - } 245 - 246 - // Final status 247 - fmt.Printf("\n") 248 - if !c.HasDifferences() { 249 - fmt.Printf("✓ Indexes are identical\n") 250 - } else { 251 - fmt.Printf("✗ Indexes have differences\n") 252 - if len(c.HashMismatches) > 0 { 253 - fmt.Printf("\n⚠️ WARNING: Chain hash mismatches detected!\n") 254 - fmt.Printf("This indicates different bundle content or chain integrity issues.\n") 255 - } 256 - } 257 - } 258 - 259 - func showHashMismatches(mismatches []HashMismatch, verbose bool) { 260 - fmt.Printf("\n⚠️ CHAIN HASH MISMATCHES (CRITICAL)\n") 261 - fmt.Printf("════════════════════════════════════\n\n") 262 - 263 - displayCount := len(mismatches) 264 - if displayCount > 10 && !verbose { 265 - displayCount = 10 266 - } 267 - 268 - for i := 0; i < displayCount; i++ { 269 - m := mismatches[i] 270 - fmt.Printf(" Bundle %06d:\n", m.BundleNumber) 271 - fmt.Printf(" Chain Hash:\n") 272 - fmt.Printf(" Local: %s\n", m.LocalHash) 273 - fmt.Printf(" Target: %s\n", m.TargetHash) 274 - 275 - if m.LocalContentHash != m.TargetContentHash { 276 - fmt.Printf(" Content Hash (also differs):\n") 277 - fmt.Printf(" Local: %s\n", m.LocalContentHash) 278 - fmt.Printf(" Target: %s\n", m.TargetContentHash) 279 - } 280 - fmt.Printf("\n") 281 - } 282 - 283 - if len(mismatches) > displayCount { 284 - fmt.Printf(" ... and %d more (use -v to show all)\n\n", len(mismatches)-displayCount) 285 - } 286 - } 287 - 288 - func showMissingBundles(bundles []int, verbose bool) { 289 - fmt.Printf("\nMissing Bundles (in target but not local)\n") 290 - fmt.Printf("──────────────────────────────────────────\n") 291 - 292 - if verbose || len(bundles) <= 20 { 293 - displayCount := len(bundles) 294 - if displayCount > 20 && !verbose { 295 - displayCount = 20 296 - } 297 - 298 - for i := 0; i < displayCount; i++ { 299 - fmt.Printf(" %06d\n", bundles[i]) 300 - } 301 - 302 - if len(bundles) > displayCount { 303 - fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount) 304 - } 305 - } else { 306 - displayBundleRanges(bundles) 307 - } 308 - } 309 - 310 - func showExtraBundles(bundles []int, verbose bool) { 311 - fmt.Printf("\nExtra Bundles (in local but not target)\n") 312 - fmt.Printf("────────────────────────────────────────\n") 313 - 314 - if verbose || len(bundles) <= 20 { 315 - displayCount := len(bundles) 316 - if displayCount > 20 && !verbose { 317 - displayCount = 20 318 - } 319 - 320 - for i := 0; i < displayCount; i++ { 321 - fmt.Printf(" %06d\n", bundles[i]) 322 - } 323 - 324 - if len(bundles) > displayCount { 325 - fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount) 326 - } 327 - } else { 328 - displayBundleRanges(bundles) 329 - } 330 - } 331 - 332 - func displayBundleRanges(bundles []int) { 333 - if len(bundles) == 0 { 334 - return 335 - } 336 - 337 - rangeStart := bundles[0] 338 - rangeEnd := bundles[0] 339 - 340 - for i := 1; i < len(bundles); i++ { 341 - if bundles[i] == rangeEnd+1 { 342 - rangeEnd = bundles[i] 343 - } else { 344 - if rangeStart == rangeEnd { 345 - fmt.Printf(" %06d\n", rangeStart) 346 - } else { 347 - fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd) 348 - } 349 - rangeStart = bundles[i] 350 - rangeEnd = bundles[i] 351 - } 352 - } 353 - 354 - if rangeStart == rangeEnd { 355 - fmt.Printf(" %06d\n", rangeStart) 356 - } else { 357 - fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd) 358 - } 359 - } 360 - 361 - func fetchMissingBundles(mgr BundleManager, baseURL string, missingBundles []int) error { 362 - client := &http.Client{Timeout: 60 * time.Second} 363 - 364 - successCount := 0 365 - errorCount := 0 366 - 367 - info := mgr.GetInfo() 368 - bundleDir := info["bundle_dir"].(string) 369 - 370 - for _, bundleNum := range missingBundles { 371 - fmt.Printf("Fetching bundle %06d... ", bundleNum) 372 - 373 - url := fmt.Sprintf("%s/data/%d", baseURL, bundleNum) 374 - resp, err := client.Get(url) 375 - if err != nil { 376 - fmt.Printf("ERROR: %v\n", err) 377 - errorCount++ 378 - continue 379 - } 380 - 381 - if resp.StatusCode != http.StatusOK { 382 - fmt.Printf("ERROR: status %d\n", resp.StatusCode) 383 - resp.Body.Close() 384 - errorCount++ 385 - continue 386 - } 387 - 388 - filename := fmt.Sprintf("%06d.jsonl.zst", bundleNum) 389 - filepath := filepath.Join(bundleDir, filename) 390 - 391 - outFile, err := os.Create(filepath) 392 - if err != nil { 393 - fmt.Printf("ERROR: %v\n", err) 394 - resp.Body.Close() 395 - errorCount++ 396 - continue 397 - } 398 - 399 - _, err = io.Copy(outFile, resp.Body) 400 - outFile.Close() 401 - resp.Body.Close() 402 - 403 - if err != nil { 404 - fmt.Printf("ERROR: %v\n", err) 405 - os.Remove(filepath) 406 - errorCount++ 407 - continue 408 - } 409 - 410 - fmt.Printf("✓\n") 411 - successCount++ 412 - time.Sleep(200 * time.Millisecond) 413 - } 414 - 415 - fmt.Printf("\n✓ Fetch complete: %d succeeded, %d failed\n", successCount, errorCount) 416 - 417 - if errorCount > 0 { 418 - return fmt.Errorf("some bundles failed to download") 419 - } 420 - 421 - return nil 422 - } 423 - 424 - // Types 425 - 426 - type IndexComparison struct { 427 - LocalCount int 428 - TargetCount int 429 - CommonCount int 430 - MissingBundles []int 431 - ExtraBundles []int 432 - HashMismatches []HashMismatch 433 - ContentMismatches []HashMismatch 434 - LocalRange [2]int 435 - TargetRange [2]int 436 - LocalTotalSize int64 437 - TargetTotalSize int64 438 - LocalUpdated time.Time 439 - TargetUpdated time.Time 440 - } 441 - 442 - type HashMismatch struct { 443 - BundleNumber int 444 - LocalHash string 445 - TargetHash string 446 - LocalContentHash string 447 - TargetContentHash string 448 - } 449 - 450 - func (ic *IndexComparison) HasDifferences() bool { 451 - return len(ic.MissingBundles) > 0 || len(ic.ExtraBundles) > 0 || 452 - len(ic.HashMismatches) > 0 || len(ic.ContentMismatches) > 0 453 - } 454 - 455 - func formatCount(count int) string { 456 - if count == 0 { 457 - return "\033[32m0 ✓\033[0m" 458 - } 459 - return fmt.Sprintf("\033[33m%d ⚠️\033[0m", count) 460 - } 461 - 462 - func formatCountCritical(count int) string { 463 - if count == 0 { 464 - return "\033[32m0 ✓\033[0m" 465 - } 466 - return fmt.Sprintf("\033[31m%d ✗\033[0m", count) 467 - }
+801
cmd/plcbundle/commands/diff.go
··· 1 + // repo/cmd/plcbundle/commands/diff.go 2 + package commands 3 + 4 + import ( 5 + "context" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "sort" 10 + "strings" 11 + "time" 12 + 13 + "github.com/goccy/go-json" 14 + "github.com/spf13/cobra" 15 + "tangled.org/atscan.net/plcbundle/bundle" 16 + "tangled.org/atscan.net/plcbundle/internal/bundleindex" 17 + "tangled.org/atscan.net/plcbundle/internal/plcclient" 18 + ) 19 + 20 + func NewDiffCommand() *cobra.Command { 21 + var ( 22 + verbose bool 23 + bundleNum int 24 + showOperations bool 25 + showSample int 26 + ) 27 + 28 + cmd := &cobra.Command{ 29 + Use: "diff <target>", 30 + Aliases: []string{"compare"}, 31 + Short: "Compare repositories", 32 + Long: `Compare local repository against remote or local target 33 + 34 + Compares bundle indexes to find differences such as: 35 + • Missing bundles (in target but not local) 36 + • Extra bundles (in local but not target) 37 + • Hash mismatches (different content) 38 + • Content mismatches (different data) 39 + 40 + For deeper analysis of specific bundles, use --bundle flag to see 41 + detailed differences in metadata and operations. 42 + 43 + The target can be: 44 + • Remote HTTP URL (e.g., https://plc.example.com) 45 + • Remote index URL (e.g., https://plc.example.com/index.json) 46 + • Local file path (e.g., /path/to/plc_bundles.json)`, 47 + 48 + Example: ` # High-level comparison 49 + plcbundle diff https://plc.example.com 50 + 51 + # Show all differences (verbose) 52 + plcbundle diff https://plc.example.com -v 53 + 54 + # Deep dive into specific bundle 55 + plcbundle diff https://plc.example.com --bundle 23 56 + 57 + # Compare bundle with operation samples 58 + plcbundle diff https://plc.example.com --bundle 23 --show-operations 59 + 60 + # Show first 50 operations 61 + plcbundle diff https://plc.example.com --bundle 23 --sample 50 62 + 63 + # Using alias 64 + plcbundle compare https://plc.example.com`, 65 + 66 + Args: cobra.ExactArgs(1), 67 + 68 + RunE: func(cmd *cobra.Command, args []string) error { 69 + target := args[0] 70 + 71 + mgr, dir, err := getManagerFromCommand(cmd, "") 72 + if err != nil { 73 + return err 74 + } 75 + defer mgr.Close() 76 + 77 + // If specific bundle requested, do detailed diff 78 + if bundleNum > 0 { 79 + return diffSpecificBundle(mgr, target, bundleNum, showOperations, showSample) 80 + } 81 + 82 + // Otherwise, do high-level index comparison 83 + return diffIndexes(mgr, dir, target, verbose) 84 + }, 85 + } 86 + 87 + // Flags 88 + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show all differences (verbose output)") 89 + cmd.Flags().IntVarP(&bundleNum, "bundle", "b", 0, "Deep diff of specific bundle") 90 + cmd.Flags().BoolVar(&showOperations, "show-operations", false, "Show operation differences (use with --bundle)") 91 + cmd.Flags().IntVar(&showSample, "sample", 10, "Number of sample operations to show (use with --bundle)") 92 + 93 + return cmd 94 + } 95 + 96 + // diffIndexes performs high-level index comparison 97 + func diffIndexes(mgr BundleManager, dir string, target string, verbose bool) error { 98 + fmt.Printf("Comparing: %s\n", dir) 99 + fmt.Printf(" Against: %s\n\n", target) 100 + 101 + // Load local index 102 + localIndex := mgr.GetIndex() 103 + 104 + // Load target index 105 + fmt.Printf("Loading target index...\n") 106 + targetIndex, err := loadTargetIndex(target) 107 + if err != nil { 108 + return fmt.Errorf("error loading target index: %w", err) 109 + } 110 + 111 + // Perform comparison 112 + comparison := compareIndexes(localIndex, targetIndex) 113 + 114 + // Display results 115 + displayComparison(comparison, verbose) 116 + 117 + // If there are hash mismatches, suggest deep dive 118 + if len(comparison.HashMismatches) > 0 { 119 + fmt.Printf("\n💡 Tip: Use --bundle flag to investigate specific mismatches:\n") 120 + fmt.Printf(" plcbundle diff %s --bundle %d --show-operations\n", 121 + target, comparison.HashMismatches[0].BundleNumber) 122 + } 123 + 124 + if comparison.HasDifferences() { 125 + return fmt.Errorf("indexes have differences") 126 + } 127 + 128 + return nil 129 + } 130 + 131 + // diffSpecificBundle performs detailed comparison of a specific bundle 132 + func diffSpecificBundle(mgr BundleManager, target string, bundleNum int, showOps bool, sampleSize int) error { 133 + ctx := context.Background() 134 + 135 + fmt.Printf("Deep Diff: Bundle %06d\n", bundleNum) 136 + fmt.Printf("═══════════════════════════════════════════════════════════════\n\n") 137 + 138 + // Load local bundle 139 + fmt.Printf("Loading local bundle %06d...\n", bundleNum) 140 + localBundle, err := mgr.LoadBundle(ctx, bundleNum) 141 + if err != nil { 142 + return fmt.Errorf("failed to load local bundle: %w", err) 143 + } 144 + 145 + // Load remote index to get metadata 146 + fmt.Printf("Loading remote index...\n") 147 + remoteIndex, err := loadTargetIndex(target) 148 + if err != nil { 149 + return fmt.Errorf("failed to load remote index: %w", err) 150 + } 151 + 152 + remoteMeta, err := remoteIndex.GetBundle(bundleNum) 153 + if err != nil { 154 + return fmt.Errorf("bundle not found in remote index: %w", err) 155 + } 156 + 157 + // Load remote bundle 158 + fmt.Printf("Loading remote bundle %06d...\n\n", bundleNum) 159 + remoteOps, err := loadRemoteBundle(target, bundleNum) 160 + if err != nil { 161 + return fmt.Errorf("failed to load remote bundle: %w", err) 162 + } 163 + 164 + // Compare metadata 165 + displayBundleMetadataComparison(localBundle, remoteMeta) 166 + 167 + // Compare operations 168 + if showOps { 169 + fmt.Printf("\n") 170 + displayOperationComparison(localBundle.Operations, remoteOps, sampleSize) 171 + } 172 + 173 + // Compare hashes in detail 174 + fmt.Printf("\n") 175 + displayHashAnalysis(localBundle, remoteMeta) 176 + 177 + return nil 178 + } 179 + 180 + // displayBundleMetadataComparison shows metadata comparison 181 + func displayBundleMetadataComparison(local *bundle.Bundle, remote *bundleindex.BundleMetadata) { 182 + fmt.Printf("Metadata Comparison\n") 183 + fmt.Printf("───────────────────\n\n") 184 + 185 + // Basic info 186 + fmt.Printf(" Bundle Number: %06d\n", local.BundleNumber) 187 + 188 + // Times 189 + timeMatch := local.StartTime.Equal(remote.StartTime) && local.EndTime.Equal(remote.EndTime) 190 + fmt.Printf(" Start Time: %s %s\n", 191 + formatTimeDiff(local.StartTime, remote.StartTime), 192 + statusIcon(timeMatch)) 193 + fmt.Printf(" Local: %s\n", local.StartTime.Format(time.RFC3339)) 194 + fmt.Printf(" Remote: %s\n", remote.StartTime.Format(time.RFC3339)) 195 + 196 + fmt.Printf(" End Time: %s %s\n", 197 + formatTimeDiff(local.EndTime, remote.EndTime), 198 + statusIcon(timeMatch)) 199 + fmt.Printf(" Local: %s\n", local.EndTime.Format(time.RFC3339)) 200 + fmt.Printf(" Remote: %s\n", remote.EndTime.Format(time.RFC3339)) 201 + 202 + // Counts 203 + opCountMatch := len(local.Operations) == remote.OperationCount 204 + didCountMatch := local.DIDCount == remote.DIDCount 205 + 206 + fmt.Printf(" Operation Count: %s %s\n", 207 + formatCountDiff(len(local.Operations), remote.OperationCount), 208 + statusIcon(opCountMatch)) 209 + fmt.Printf(" DID Count: %s %s\n", 210 + formatCountDiff(local.DIDCount, remote.DIDCount), 211 + statusIcon(didCountMatch)) 212 + 213 + // Sizes 214 + sizeMatch := local.CompressedSize == remote.CompressedSize 215 + fmt.Printf(" Compressed Size: %s %s\n", 216 + formatSizeDiff(local.CompressedSize, remote.CompressedSize), 217 + statusIcon(sizeMatch)) 218 + 219 + uncompMatch := local.UncompressedSize == remote.UncompressedSize 220 + fmt.Printf(" Uncompressed Size: %s %s\n", 221 + formatSizeDiff(local.UncompressedSize, remote.UncompressedSize), 222 + statusIcon(uncompMatch)) 223 + } 224 + 225 + // displayHashAnalysis shows detailed hash comparison 226 + func displayHashAnalysis(local *bundle.Bundle, remote *bundleindex.BundleMetadata) { 227 + fmt.Printf("Hash Analysis\n") 228 + fmt.Printf("═════════════\n\n") 229 + 230 + // Content hash (most important) 231 + contentMatch := local.ContentHash == remote.ContentHash 232 + fmt.Printf(" Content Hash: %s\n", statusIcon(contentMatch)) 233 + fmt.Printf(" Local: %s\n", local.ContentHash) 234 + fmt.Printf(" Remote: %s\n", remote.ContentHash) 235 + if !contentMatch { 236 + fmt.Printf(" ⚠️ Different bundle content!\n") 237 + } 238 + fmt.Printf("\n") 239 + 240 + // Compressed hash 241 + compMatch := local.CompressedHash == remote.CompressedHash 242 + fmt.Printf(" Compressed Hash: %s\n", statusIcon(compMatch)) 243 + fmt.Printf(" Local: %s\n", local.CompressedHash) 244 + fmt.Printf(" Remote: %s\n", remote.CompressedHash) 245 + if !compMatch && contentMatch { 246 + fmt.Printf(" ℹ️ Different compression (same content)\n") 247 + } 248 + fmt.Printf("\n") 249 + 250 + // Chain hash 251 + chainMatch := local.Hash == remote.Hash 252 + fmt.Printf(" Chain Hash: %s\n", statusIcon(chainMatch)) 253 + fmt.Printf(" Local: %s\n", local.Hash) 254 + fmt.Printf(" Remote: %s\n", remote.Hash) 255 + if !chainMatch { 256 + // Analyze why chain hash differs 257 + parentMatch := local.Parent == remote.Parent 258 + fmt.Printf("\n Chain Components:\n") 259 + fmt.Printf(" Parent: %s\n", statusIcon(parentMatch)) 260 + fmt.Printf(" Local: %s\n", local.Parent) 261 + fmt.Printf(" Remote: %s\n", remote.Parent) 262 + 263 + if !parentMatch { 264 + fmt.Printf(" ⚠️ Different parent → chain diverged at earlier bundle\n") 265 + } else if !contentMatch { 266 + fmt.Printf(" ⚠️ Same parent but different content → different operations\n") 267 + } 268 + } 269 + } 270 + 271 + // displayOperationComparison shows operation differences 272 + func displayOperationComparison(localOps []plcclient.PLCOperation, remoteOps []plcclient.PLCOperation, sampleSize int) { 273 + fmt.Printf("Operation Comparison\n") 274 + fmt.Printf("════════════════════\n\n") 275 + 276 + if len(localOps) != len(remoteOps) { 277 + fmt.Printf(" ⚠️ Different operation counts: local=%d, remote=%d\n\n", 278 + len(localOps), len(remoteOps)) 279 + } 280 + 281 + // Build CID sets for comparison 282 + localCIDs := make(map[string]int) 283 + remoteCIDs := make(map[string]int) 284 + 285 + for i, op := range localOps { 286 + localCIDs[op.CID] = i 287 + } 288 + for i, op := range remoteOps { 289 + remoteCIDs[op.CID] = i 290 + } 291 + 292 + // Find differences 293 + var missingInLocal []string 294 + var missingInRemote []string 295 + var positionMismatches []string 296 + 297 + for cid, remotePos := range remoteCIDs { 298 + if localPos, exists := localCIDs[cid]; !exists { 299 + missingInLocal = append(missingInLocal, cid) 300 + } else if localPos != remotePos { 301 + positionMismatches = append(positionMismatches, cid) 302 + } 303 + } 304 + 305 + for cid := range localCIDs { 306 + if _, exists := remoteCIDs[cid]; !exists { 307 + missingInRemote = append(missingInRemote, cid) 308 + } 309 + } 310 + 311 + // Display differences 312 + if len(missingInLocal) > 0 { 313 + fmt.Printf(" Missing in Local (%d operations):\n", len(missingInLocal)) 314 + displaySample := min(sampleSize, len(missingInLocal)) 315 + for i := 0; i < displaySample; i++ { 316 + cid := missingInLocal[i] 317 + pos := remoteCIDs[cid] 318 + fmt.Printf(" - [%04d] %s\n", pos, cid) 319 + } 320 + if len(missingInLocal) > displaySample { 321 + fmt.Printf(" ... and %d more\n", len(missingInLocal)-displaySample) 322 + } 323 + fmt.Printf("\n") 324 + } 325 + 326 + if len(missingInRemote) > 0 { 327 + fmt.Printf(" Missing in Remote (%d operations):\n", len(missingInRemote)) 328 + displaySample := min(sampleSize, len(missingInRemote)) 329 + for i := 0; i < displaySample; i++ { 330 + cid := missingInRemote[i] 331 + pos := localCIDs[cid] 332 + fmt.Printf(" + [%04d] %s\n", pos, cid) 333 + } 334 + if len(missingInRemote) > displaySample { 335 + fmt.Printf(" ... and %d more\n", len(missingInRemote)-displaySample) 336 + } 337 + fmt.Printf("\n") 338 + } 339 + 340 + if len(positionMismatches) > 0 { 341 + fmt.Printf(" Position Mismatches (%d operations):\n", len(positionMismatches)) 342 + displaySample := min(sampleSize, len(positionMismatches)) 343 + for i := 0; i < displaySample; i++ { 344 + cid := positionMismatches[i] 345 + localPos := localCIDs[cid] 346 + remotePos := remoteCIDs[cid] 347 + fmt.Printf(" ~ %s\n", cid) 348 + fmt.Printf(" Local: position %04d\n", localPos) 349 + fmt.Printf(" Remote: position %04d\n", remotePos) 350 + } 351 + if len(positionMismatches) > displaySample { 352 + fmt.Printf(" ... and %d more\n", len(positionMismatches)-displaySample) 353 + } 354 + fmt.Printf("\n") 355 + } 356 + 357 + if len(missingInLocal) == 0 && len(missingInRemote) == 0 && len(positionMismatches) == 0 { 358 + fmt.Printf(" ✓ All operations match (same CIDs, same order)\n\n") 359 + } 360 + 361 + // Show sample operations for context 362 + if len(localOps) > 0 { 363 + fmt.Printf("Sample Operations (first %d):\n", min(sampleSize, len(localOps))) 364 + fmt.Printf("────────────────────────────────\n") 365 + for i := 0; i < min(sampleSize, len(localOps)); i++ { 366 + op := localOps[i] 367 + remoteMatch := "" 368 + if remotePos, exists := remoteCIDs[op.CID]; exists { 369 + if remotePos == i { 370 + remoteMatch = " ✓" 371 + } else { 372 + remoteMatch = fmt.Sprintf(" ~ (remote pos: %04d)", remotePos) 373 + } 374 + } else { 375 + remoteMatch = " ✗ (missing in remote)" 376 + } 377 + 378 + fmt.Printf(" [%04d] %s%s\n", i, op.CID, remoteMatch) 379 + fmt.Printf(" DID: %s\n", op.DID) 380 + fmt.Printf(" Time: %s\n", op.CreatedAt.Format(time.RFC3339)) 381 + } 382 + fmt.Printf("\n") 383 + } 384 + } 385 + 386 + // loadRemoteBundle loads a bundle from remote server 387 + func loadRemoteBundle(baseURL string, bundleNum int) ([]plcclient.PLCOperation, error) { 388 + // Determine the data URL 389 + url := baseURL 390 + if !strings.HasSuffix(url, ".json") { 391 + url = strings.TrimSuffix(url, "/") 392 + } 393 + url = strings.TrimSuffix(url, "/index.json") 394 + url = strings.TrimSuffix(url, "/plc_bundles.json") 395 + url = fmt.Sprintf("%s/jsonl/%d", url, bundleNum) 396 + 397 + client := &http.Client{Timeout: 60 * time.Second} 398 + 399 + resp, err := client.Get(url) 400 + if err != nil { 401 + return nil, fmt.Errorf("failed to download: %w", err) 402 + } 403 + defer resp.Body.Close() 404 + 405 + if resp.StatusCode != http.StatusOK { 406 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 407 + } 408 + 409 + // Parse JSONL 410 + var operations []plcclient.PLCOperation 411 + decoder := json.NewDecoder(resp.Body) 412 + 413 + for { 414 + var op plcclient.PLCOperation 415 + if err := decoder.Decode(&op); err != nil { 416 + if err == io.EOF { 417 + break 418 + } 419 + return nil, fmt.Errorf("failed to parse operation: %w", err) 420 + } 421 + operations = append(operations, op) 422 + } 423 + 424 + return operations, nil 425 + } 426 + 427 + // loadTargetIndex loads index from URL or local path 428 + func loadTargetIndex(target string) (*bundleindex.Index, error) { 429 + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { 430 + return loadIndexFromURL(target) 431 + } 432 + return bundleindex.LoadIndex(target) 433 + } 434 + 435 + // loadIndexFromURL loads index from remote URL 436 + func loadIndexFromURL(url string) (*bundleindex.Index, error) { 437 + if !strings.HasSuffix(url, ".json") { 438 + url = strings.TrimSuffix(url, "/") + "/index.json" 439 + } 440 + 441 + client := &http.Client{Timeout: 30 * time.Second} 442 + 443 + resp, err := client.Get(url) 444 + if err != nil { 445 + return nil, fmt.Errorf("failed to download: %w", err) 446 + } 447 + defer resp.Body.Close() 448 + 449 + if resp.StatusCode != http.StatusOK { 450 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 451 + } 452 + 453 + data, err := io.ReadAll(resp.Body) 454 + if err != nil { 455 + return nil, fmt.Errorf("failed to read response: %w", err) 456 + } 457 + 458 + var idx bundleindex.Index 459 + if err := json.Unmarshal(data, &idx); err != nil { 460 + return nil, fmt.Errorf("failed to parse index: %w", err) 461 + } 462 + 463 + return &idx, nil 464 + } 465 + 466 + // compareIndexes performs the actual comparison 467 + func compareIndexes(local, target *bundleindex.Index) *IndexComparison { 468 + localBundles := local.GetBundles() 469 + targetBundles := target.GetBundles() 470 + 471 + localMap := make(map[int]*bundleindex.BundleMetadata) 472 + targetMap := make(map[int]*bundleindex.BundleMetadata) 473 + 474 + for _, b := range localBundles { 475 + localMap[b.BundleNumber] = b 476 + } 477 + for _, b := range targetBundles { 478 + targetMap[b.BundleNumber] = b 479 + } 480 + 481 + comparison := &IndexComparison{ 482 + LocalCount: len(localBundles), 483 + TargetCount: len(targetBundles), 484 + MissingBundles: make([]int, 0), 485 + ExtraBundles: make([]int, 0), 486 + HashMismatches: make([]HashMismatch, 0), 487 + ContentMismatches: make([]HashMismatch, 0), 488 + } 489 + 490 + // Get ranges 491 + if len(localBundles) > 0 { 492 + comparison.LocalRange = [2]int{localBundles[0].BundleNumber, localBundles[len(localBundles)-1].BundleNumber} 493 + comparison.LocalUpdated = local.UpdatedAt 494 + comparison.LocalTotalSize = local.TotalSize 495 + } 496 + 497 + if len(targetBundles) > 0 { 498 + comparison.TargetRange = [2]int{targetBundles[0].BundleNumber, targetBundles[len(targetBundles)-1].BundleNumber} 499 + comparison.TargetUpdated = target.UpdatedAt 500 + comparison.TargetTotalSize = target.TotalSize 501 + } 502 + 503 + // Find missing bundles 504 + for bundleNum := range targetMap { 505 + if _, exists := localMap[bundleNum]; !exists { 506 + comparison.MissingBundles = append(comparison.MissingBundles, bundleNum) 507 + } 508 + } 509 + sort.Ints(comparison.MissingBundles) 510 + 511 + // Find extra bundles 512 + for bundleNum := range localMap { 513 + if _, exists := targetMap[bundleNum]; !exists { 514 + comparison.ExtraBundles = append(comparison.ExtraBundles, bundleNum) 515 + } 516 + } 517 + sort.Ints(comparison.ExtraBundles) 518 + 519 + // Compare hashes 520 + for bundleNum, localMeta := range localMap { 521 + if targetMeta, exists := targetMap[bundleNum]; exists { 522 + comparison.CommonCount++ 523 + 524 + chainMismatch := localMeta.Hash != targetMeta.Hash 525 + contentMismatch := localMeta.ContentHash != targetMeta.ContentHash 526 + 527 + if chainMismatch || contentMismatch { 528 + mismatch := HashMismatch{ 529 + BundleNumber: bundleNum, 530 + LocalHash: localMeta.Hash, 531 + TargetHash: targetMeta.Hash, 532 + LocalContentHash: localMeta.ContentHash, 533 + TargetContentHash: targetMeta.ContentHash, 534 + } 535 + 536 + if chainMismatch { 537 + comparison.HashMismatches = append(comparison.HashMismatches, mismatch) 538 + } 539 + if contentMismatch && !chainMismatch { 540 + comparison.ContentMismatches = append(comparison.ContentMismatches, mismatch) 541 + } 542 + } 543 + } 544 + } 545 + 546 + return comparison 547 + } 548 + 549 + // displayComparison shows comparison results 550 + func displayComparison(c *IndexComparison, verbose bool) { 551 + fmt.Printf("Comparison Results\n") 552 + fmt.Printf("══════════════════\n\n") 553 + 554 + fmt.Printf("Summary\n───────\n") 555 + fmt.Printf(" Local bundles: %d\n", c.LocalCount) 556 + fmt.Printf(" Target bundles: %d\n", c.TargetCount) 557 + fmt.Printf(" Common bundles: %d\n", c.CommonCount) 558 + fmt.Printf(" Missing bundles: %s\n", formatCount(len(c.MissingBundles))) 559 + fmt.Printf(" Extra bundles: %s\n", formatCount(len(c.ExtraBundles))) 560 + fmt.Printf(" Hash mismatches: %s\n", formatCountCritical(len(c.HashMismatches))) 561 + fmt.Printf(" Content mismatches: %s\n", formatCount(len(c.ContentMismatches))) 562 + 563 + if c.LocalCount > 0 { 564 + fmt.Printf("\n Local range: %06d - %06d\n", c.LocalRange[0], c.LocalRange[1]) 565 + fmt.Printf(" Local size: %.2f MB\n", float64(c.LocalTotalSize)/(1024*1024)) 566 + fmt.Printf(" Local updated: %s\n", c.LocalUpdated.Format("2006-01-02 15:04:05")) 567 + } 568 + 569 + if c.TargetCount > 0 { 570 + fmt.Printf("\n Target range: %06d - %06d\n", c.TargetRange[0], c.TargetRange[1]) 571 + fmt.Printf(" Target size: %.2f MB\n", float64(c.TargetTotalSize)/(1024*1024)) 572 + fmt.Printf(" Target updated: %s\n", c.TargetUpdated.Format("2006-01-02 15:04:05")) 573 + } 574 + 575 + // Show differences 576 + if len(c.HashMismatches) > 0 { 577 + showHashMismatches(c.HashMismatches, verbose) 578 + } 579 + 580 + if len(c.MissingBundles) > 0 { 581 + showMissingBundles(c.MissingBundles, verbose) 582 + } 583 + 584 + if len(c.ExtraBundles) > 0 { 585 + showExtraBundles(c.ExtraBundles, verbose) 586 + } 587 + 588 + // Final status 589 + fmt.Printf("\n") 590 + if !c.HasDifferences() { 591 + fmt.Printf("✓ Indexes are identical\n") 592 + } else { 593 + fmt.Printf("✗ Indexes have differences\n") 594 + if len(c.HashMismatches) > 0 { 595 + fmt.Printf("\n⚠️ WARNING: Chain hash mismatches detected!\n") 596 + fmt.Printf("This indicates different bundle content or chain integrity issues.\n") 597 + } 598 + } 599 + } 600 + 601 + // showHashMismatches displays hash mismatches 602 + func showHashMismatches(mismatches []HashMismatch, verbose bool) { 603 + fmt.Printf("\n⚠️ CHAIN HASH MISMATCHES (CRITICAL)\n") 604 + fmt.Printf("════════════════════════════════════\n\n") 605 + 606 + displayCount := len(mismatches) 607 + if displayCount > 10 && !verbose { 608 + displayCount = 10 609 + } 610 + 611 + for i := 0; i < displayCount; i++ { 612 + m := mismatches[i] 613 + fmt.Printf(" Bundle %06d:\n", m.BundleNumber) 614 + fmt.Printf(" Chain Hash:\n") 615 + fmt.Printf(" Local: %s\n", m.LocalHash) 616 + fmt.Printf(" Target: %s\n", m.TargetHash) 617 + 618 + if m.LocalContentHash != m.TargetContentHash { 619 + fmt.Printf(" Content Hash (also differs):\n") 620 + fmt.Printf(" Local: %s\n", m.LocalContentHash) 621 + fmt.Printf(" Target: %s\n", m.TargetContentHash) 622 + } 623 + fmt.Printf("\n") 624 + } 625 + 626 + if len(mismatches) > displayCount { 627 + fmt.Printf(" ... and %d more (use -v to show all)\n\n", len(mismatches)-displayCount) 628 + } 629 + } 630 + 631 + // showMissingBundles displays missing bundles 632 + func showMissingBundles(bundles []int, verbose bool) { 633 + fmt.Printf("\nMissing Bundles (in target but not local)\n") 634 + fmt.Printf("──────────────────────────────────────────\n") 635 + 636 + if verbose || len(bundles) <= 20 { 637 + displayCount := len(bundles) 638 + if displayCount > 20 && !verbose { 639 + displayCount = 20 640 + } 641 + 642 + for i := 0; i < displayCount; i++ { 643 + fmt.Printf(" %06d\n", bundles[i]) 644 + } 645 + 646 + if len(bundles) > displayCount { 647 + fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount) 648 + } 649 + } else { 650 + displayBundleRanges(bundles) 651 + } 652 + } 653 + 654 + // showExtraBundles displays extra bundles 655 + func showExtraBundles(bundles []int, verbose bool) { 656 + fmt.Printf("\nExtra Bundles (in local but not target)\n") 657 + fmt.Printf("────────────────────────────────────────\n") 658 + 659 + if verbose || len(bundles) <= 20 { 660 + displayCount := len(bundles) 661 + if displayCount > 20 && !verbose { 662 + displayCount = 20 663 + } 664 + 665 + for i := 0; i < displayCount; i++ { 666 + fmt.Printf(" %06d\n", bundles[i]) 667 + } 668 + 669 + if len(bundles) > displayCount { 670 + fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount) 671 + } 672 + } else { 673 + displayBundleRanges(bundles) 674 + } 675 + } 676 + 677 + // displayBundleRanges displays bundles as ranges 678 + func displayBundleRanges(bundles []int) { 679 + if len(bundles) == 0 { 680 + return 681 + } 682 + 683 + rangeStart := bundles[0] 684 + rangeEnd := bundles[0] 685 + 686 + for i := 1; i < len(bundles); i++ { 687 + if bundles[i] == rangeEnd+1 { 688 + rangeEnd = bundles[i] 689 + } else { 690 + if rangeStart == rangeEnd { 691 + fmt.Printf(" %06d\n", rangeStart) 692 + } else { 693 + fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd) 694 + } 695 + rangeStart = bundles[i] 696 + rangeEnd = bundles[i] 697 + } 698 + } 699 + 700 + if rangeStart == rangeEnd { 701 + fmt.Printf(" %06d\n", rangeStart) 702 + } else { 703 + fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd) 704 + } 705 + } 706 + 707 + // Helper formatting functions 708 + 709 + func statusIcon(match bool) string { 710 + if match { 711 + return "✓" 712 + } 713 + return "✗" 714 + } 715 + 716 + func formatTimeDiff(local, remote time.Time) string { 717 + if local.Equal(remote) { 718 + return "identical" 719 + } 720 + diff := local.Sub(remote) 721 + if diff < 0 { 722 + diff = -diff 723 + } 724 + return fmt.Sprintf("differs by %s", diff) 725 + } 726 + 727 + func formatCountDiff(local, remote int) string { 728 + if local == remote { 729 + return fmt.Sprintf("%d", local) 730 + } 731 + return fmt.Sprintf("local=%d, remote=%d", local, remote) 732 + } 733 + 734 + func formatSizeDiff(local, remote int64) string { 735 + if local == remote { 736 + return formatBytes(local) 737 + } 738 + diff := local - remote 739 + sign := "+" 740 + if diff < 0 { 741 + sign = "-" 742 + diff = -diff 743 + } 744 + return fmt.Sprintf("local=%s, remote=%s (%s%s)", 745 + formatBytes(local), formatBytes(remote), sign, formatBytes(diff)) 746 + } 747 + 748 + // IndexComparison holds comparison results 749 + type IndexComparison struct { 750 + LocalCount int 751 + TargetCount int 752 + CommonCount int 753 + MissingBundles []int 754 + ExtraBundles []int 755 + HashMismatches []HashMismatch 756 + ContentMismatches []HashMismatch 757 + LocalRange [2]int 758 + TargetRange [2]int 759 + LocalTotalSize int64 760 + TargetTotalSize int64 761 + LocalUpdated time.Time 762 + TargetUpdated time.Time 763 + } 764 + 765 + // HashMismatch represents a hash mismatch between bundles 766 + type HashMismatch struct { 767 + BundleNumber int 768 + LocalHash string 769 + TargetHash string 770 + LocalContentHash string 771 + TargetContentHash string 772 + } 773 + 774 + // HasDifferences checks if there are any differences 775 + func (ic *IndexComparison) HasDifferences() bool { 776 + return len(ic.MissingBundles) > 0 || len(ic.ExtraBundles) > 0 || 777 + len(ic.HashMismatches) > 0 || len(ic.ContentMismatches) > 0 778 + } 779 + 780 + // formatCount formats count with color coding 781 + func formatCount(count int) string { 782 + if count == 0 { 783 + return "\033[32m0 ✓\033[0m" 784 + } 785 + return fmt.Sprintf("\033[33m%d ⚠️\033[0m", count) 786 + } 787 + 788 + // formatCountCritical formats count with critical color coding 789 + func formatCountCritical(count int) string { 790 + if count == 0 { 791 + return "\033[32m0 ✓\033[0m" 792 + } 793 + return fmt.Sprintf("\033[31m%d ✗\033[0m", count) 794 + } 795 + 796 + func min(a, b int) int { 797 + if a < b { 798 + return a 799 + } 800 + return b 801 + }
+2 -2
cmd/plcbundle/main.go
··· 57 57 /*cmd.AddCommand(commands.NewLogCommand()) 58 58 cmd.AddCommand(commands.NewGapsCommand())*/ 59 59 cmd.AddCommand(commands.NewVerifyCommand()) 60 - /*cmd.AddCommand(commands.NewDiffCommand()) 61 - cmd.AddCommand(commands.NewStatsCommand()) 60 + cmd.AddCommand(commands.NewDiffCommand()) 61 + /*cmd.AddCommand(commands.NewStatsCommand()) 62 62 cmd.AddCommand(commands.NewInspectCommand()) 63 63 64 64 // Namespaced commands