[DEPRECATED] Go implementation of plcbundle

cmd rollback

+485 -151
+25 -1
cmd/plcbundle/commands/common.go
··· 30 30 RefreshMempool() error 31 31 ClearMempool() error 32 32 FetchNextBundle(ctx context.Context, quiet bool) (*bundle.Bundle, error) 33 - SaveBundle(ctx context.Context, b *bundle.Bundle, quiet bool) (time.Duration, error) // ✨ Updated signature 33 + SaveBundle(ctx context.Context, b *bundle.Bundle, quiet bool) (time.Duration, error) 34 + SaveIndex() error 34 35 GetDIDIndexStats() map[string]interface{} 35 36 GetDIDIndex() *didindex.Manager 36 37 BuildDIDIndex(ctx context.Context, progress func(int, int)) error ··· 212 213 fmt.Fprintln(os.Stderr, v...) 213 214 } 214 215 } 216 + 217 + // formatCount formats count with color coding 218 + func formatCount(count int) string { 219 + if count == 0 { 220 + return "\033[32m0 ✓\033[0m" 221 + } 222 + return fmt.Sprintf("\033[33m%d ⚠️\033[0m", count) 223 + } 224 + 225 + // formatCountCritical formats count with critical color coding 226 + func formatCountCritical(count int) string { 227 + if count == 0 { 228 + return "\033[32m0 ✓\033[0m" 229 + } 230 + return fmt.Sprintf("\033[31m%d ✗\033[0m", count) 231 + } 232 + 233 + func min(a, b int) int { 234 + if a < b { 235 + return a 236 + } 237 + return b 238 + }
-23
cmd/plcbundle/commands/diff.go
··· 776 776 return len(ic.MissingBundles) > 0 || len(ic.ExtraBundles) > 0 || 777 777 len(ic.HashMismatches) > 0 || len(ic.ContentMismatches) > 0 778 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 - }
+458 -125
cmd/plcbundle/commands/rollback.go
··· 7 7 "path/filepath" 8 8 "strings" 9 9 10 - flag "github.com/spf13/pflag" 11 - 10 + "github.com/spf13/cobra" 11 + "tangled.org/atscan.net/plcbundle/cmd/plcbundle/ui" 12 12 "tangled.org/atscan.net/plcbundle/internal/bundleindex" 13 13 ) 14 14 15 - // RollbackCommand handles the rollback subcommand 16 - func RollbackCommand(args []string) error { 17 - fs := flag.NewFlagSet("rollback", flag.ExitOnError) 18 - toBundle := fs.Int("to", 0, "rollback TO this bundle (keeps it, removes everything after)") 19 - last := fs.Int("last", 0, "rollback last N bundles") 20 - force := fs.Bool("force", false, "skip confirmation") 21 - rebuildDIDIndex := fs.Bool("rebuild-did-index", false, "rebuild DID index after rollback") 15 + func NewRollbackCommand() *cobra.Command { 16 + var ( 17 + toBundle int 18 + last int 19 + force bool 20 + rebuildDIDIndex bool 21 + keepFiles bool 22 + ) 23 + 24 + cmd := &cobra.Command{ 25 + Use: "rollback [flags]", 26 + Short: "Rollback repository to earlier state", 27 + Long: `Rollback repository to an earlier bundle state 28 + 29 + Removes bundles from the repository and resets the state to a specific 30 + bundle. This is useful for: 31 + • Fixing corrupted bundles 32 + • Testing and development 33 + • Reverting unwanted changes 34 + • Chain integrity issues 35 + 36 + IMPORTANT: This is a destructive operation that permanently deletes data. 37 + Always verify your target bundle before proceeding. 38 + 39 + The rollback process: 40 + 1. Validates target state 41 + 2. Shows detailed rollback plan 42 + 3. Requires confirmation (unless --force) 43 + 4. Deletes bundle files 44 + 5. Clears mempool (incompatible with new state) 45 + 6. Updates bundle index 46 + 7. Optionally rebuilds DID index`, 47 + 48 + Example: ` # Rollback TO bundle 100 (keeps 1-100, removes 101+) 49 + plcbundle rollback --to 100 22 50 23 - if err := fs.Parse(args); err != nil { 24 - return err 51 + # Remove last 5 bundles 52 + plcbundle rollback --last 5 53 + 54 + # Rollback without confirmation 55 + plcbundle rollback --to 50 --force 56 + 57 + # Rollback and rebuild DID index 58 + plcbundle rollback --to 100 --rebuild-did-index 59 + 60 + # Rollback but keep bundle files (index-only) 61 + plcbundle rollback --to 100 --keep-files`, 62 + 63 + RunE: func(cmd *cobra.Command, args []string) error { 64 + verbose, _ := cmd.Root().PersistentFlags().GetBool("verbose") 65 + 66 + mgr, dir, err := getManagerFromCommand(cmd, "") 67 + if err != nil { 68 + return err 69 + } 70 + defer mgr.Close() 71 + 72 + return executeRollback(cmd.Context(), mgr, dir, rollbackOptions{ 73 + toBundle: toBundle, 74 + last: last, 75 + force: force, 76 + rebuildDIDIndex: rebuildDIDIndex, 77 + keepFiles: keepFiles, 78 + verbose: verbose, 79 + }) 80 + }, 25 81 } 26 82 27 - if *toBundle == 0 && *last == 0 { 28 - return fmt.Errorf("usage: plcbundle rollback [options]\n\n" + 29 - "Options:\n" + 30 - " --to <N> Rollback TO bundle N (keeps N, removes after)\n" + 31 - " --last <N> Rollback last N bundles\n" + 32 - " --force Skip confirmation\n" + 33 - " --rebuild-did-index Rebuild DID index after rollback\n\n" + 34 - "Examples:\n" + 35 - " plcbundle rollback --to 100 # Keep bundles 1-100\n" + 36 - " plcbundle rollback --last 5 # Remove last 5 bundles\n" + 37 - " plcbundle rollback --to 50 --force --rebuild-did-index") 83 + // Flags 84 + cmd.Flags().IntVar(&toBundle, "to", 0, "Rollback TO this bundle (keeps it)") 85 + cmd.Flags().IntVar(&last, "last", 0, "Rollback last N bundles") 86 + cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt") 87 + cmd.Flags().BoolVar(&rebuildDIDIndex, "rebuild-did-index", false, "Rebuild DID index after rollback") 88 + cmd.Flags().BoolVar(&keepFiles, "keep-files", false, "Update index only (don't delete bundle files)") 89 + 90 + return cmd 91 + } 92 + 93 + type rollbackOptions struct { 94 + toBundle int 95 + last int 96 + force bool 97 + rebuildDIDIndex bool 98 + keepFiles bool 99 + verbose bool 100 + } 101 + 102 + // rollbackPlan contains the calculated rollback plan 103 + type rollbackPlan struct { 104 + targetBundle int 105 + toKeep []*bundleindex.BundleMetadata 106 + toDelete []*bundleindex.BundleMetadata 107 + deletedOps int 108 + deletedSize int64 109 + hasMempool bool 110 + hasDIDIndex bool 111 + affectedPeriod string 112 + } 113 + 114 + func executeRollback(ctx context.Context, mgr BundleManager, dir string, opts rollbackOptions) error { 115 + // Step 1: Validate options and calculate plan 116 + plan, err := calculateRollbackPlan(mgr, opts) 117 + if err != nil { 118 + return err 38 119 } 39 120 40 - if *toBundle > 0 && *last > 0 { 41 - return fmt.Errorf("cannot use both --to and --last together") 121 + // Step 2: Display plan and get confirmation 122 + displayRollbackPlan(dir, plan) 123 + 124 + if !opts.force { 125 + if !confirmRollback(opts.keepFiles) { 126 + fmt.Println("Cancelled") 127 + return nil 128 + } 129 + fmt.Println() 42 130 } 43 131 44 - mgr, dir, err := getManager("") 45 - if err != nil { 132 + // Step 3: Execute rollback 133 + if err := performRollback(ctx, mgr, dir, plan, opts); err != nil { 46 134 return err 47 135 } 48 - defer mgr.Close() 136 + 137 + // Step 4: Display success summary 138 + displayRollbackSuccess(plan, opts) 139 + 140 + return nil 141 + } 142 + 143 + // calculateRollbackPlan determines what will be affected 144 + func calculateRollbackPlan(mgr BundleManager, opts rollbackOptions) (*rollbackPlan, error) { 145 + // Validate options 146 + if opts.toBundle == 0 && opts.last == 0 { 147 + return nil, fmt.Errorf("either --to or --last must be specified") 148 + } 149 + 150 + if opts.toBundle > 0 && opts.last > 0 { 151 + return nil, fmt.Errorf("cannot use both --to and --last together") 152 + } 49 153 50 154 index := mgr.GetIndex() 51 155 bundles := index.GetBundles() 52 156 53 157 if len(bundles) == 0 { 54 - fmt.Println("No bundles to rollback") 55 - return nil 158 + return nil, fmt.Errorf("no bundles to rollback") 56 159 } 57 160 58 - // Determine target bundle 161 + // Calculate target bundle 59 162 var targetBundle int 60 - if *toBundle > 0 { 61 - targetBundle = *toBundle 163 + if opts.toBundle > 0 { 164 + targetBundle = opts.toBundle 62 165 } else { 63 - targetBundle = bundles[len(bundles)-1].BundleNumber - *last 166 + targetBundle = bundles[len(bundles)-1].BundleNumber - opts.last 64 167 } 65 168 66 - if targetBundle < 1 { 67 - return fmt.Errorf("invalid rollback: would result in no bundles (target: %d)", targetBundle) 169 + // Validate target 170 + if targetBundle < 0 { 171 + return nil, fmt.Errorf("invalid target: would result in negative bundle number") 68 172 } 69 173 70 - // Find bundles to delete (everything AFTER target) 71 - var toDelete []*bundleindex.BundleMetadata 72 - var toKeep []*bundleindex.BundleMetadata 174 + if targetBundle < 1 && len(bundles) > 0 { 175 + return nil, fmt.Errorf("invalid rollback: would delete all bundles (use --to 0 explicitly if intended)") 176 + } 177 + 178 + // Build plan 179 + plan := &rollbackPlan{ 180 + targetBundle: targetBundle, 181 + toKeep: make([]*bundleindex.BundleMetadata, 0), 182 + toDelete: make([]*bundleindex.BundleMetadata, 0), 183 + } 73 184 74 185 for _, meta := range bundles { 75 186 if meta.BundleNumber > targetBundle { 76 - toDelete = append(toDelete, meta) 187 + plan.toDelete = append(plan.toDelete, meta) 188 + plan.deletedOps += meta.OperationCount 189 + plan.deletedSize += meta.CompressedSize 77 190 } else { 78 - toKeep = append(toKeep, meta) 191 + plan.toKeep = append(plan.toKeep, meta) 79 192 } 80 193 } 81 194 82 - if len(toDelete) == 0 { 83 - fmt.Printf("Nothing to rollback (already at bundle %d)\n", targetBundle) 84 - return nil 195 + // Check if nothing to do 196 + if len(plan.toDelete) == 0 { 197 + return nil, fmt.Errorf("already at bundle %d (nothing to rollback)", targetBundle) 85 198 } 86 199 87 - // Display rollback plan 88 - fmt.Printf("╔════════════════════════════════════════════════════════╗\n") 89 - fmt.Printf("║ ROLLBACK PLAN ║\n") 90 - fmt.Printf("╚════════════════════════════════════════════════════════╝\n\n") 91 - fmt.Printf(" Directory: %s\n", dir) 92 - fmt.Printf(" Current state: %d bundles (%06d - %06d)\n", 93 - len(bundles), bundles[0].BundleNumber, bundles[len(bundles)-1].BundleNumber) 94 - fmt.Printf(" Target: bundle %06d\n", targetBundle) 95 - fmt.Printf(" Will DELETE: %d bundle(s)\n\n", len(toDelete)) 200 + // Check for mempool 201 + mempoolStats := mgr.GetMempoolStats() 202 + plan.hasMempool = mempoolStats["count"].(int) > 0 203 + 204 + // Check for DID index 205 + didStats := mgr.GetDIDIndexStats() 206 + plan.hasDIDIndex = didStats["exists"].(bool) 96 207 97 - fmt.Printf("Bundles to delete:\n") 98 - for i, meta := range toDelete { 99 - if i < 10 || i >= len(toDelete)-5 { 100 - fmt.Printf(" %06d (%s, %s)\n", 208 + // Calculate affected time period 209 + if len(plan.toDelete) > 0 { 210 + start := plan.toDelete[0].StartTime 211 + end := plan.toDelete[len(plan.toDelete)-1].EndTime 212 + plan.affectedPeriod = fmt.Sprintf("%s to %s", 213 + start.Format("2006-01-02 15:04"), 214 + end.Format("2006-01-02 15:04")) 215 + } 216 + 217 + return plan, nil 218 + } 219 + 220 + // displayRollbackPlan shows what will happen 221 + func displayRollbackPlan(dir string, plan *rollbackPlan) { 222 + fmt.Printf("╔════════════════════════════════════════════════════════════════╗\n") 223 + fmt.Printf("║ ROLLBACK PLAN ║\n") 224 + fmt.Printf("╚════════════════════════════════════════════════════════════════╝\n\n") 225 + 226 + fmt.Printf("📁 Repository\n") 227 + fmt.Printf(" Directory: %s\n", dir) 228 + if len(plan.toKeep) > 0 { 229 + fmt.Printf(" Current state: %d bundles (%06d → %06d)\n", 230 + len(plan.toKeep)+len(plan.toDelete), 231 + plan.toKeep[0].BundleNumber, 232 + plan.toDelete[len(plan.toDelete)-1].BundleNumber) 233 + } 234 + fmt.Printf(" Target: bundle %06d\n\n", plan.targetBundle) 235 + 236 + fmt.Printf("🗑️ Will Delete\n") 237 + fmt.Printf(" Bundles: %d\n", len(plan.toDelete)) 238 + fmt.Printf(" Operations: %s\n", formatNumber(plan.deletedOps)) 239 + fmt.Printf(" Data size: %s\n", formatBytes(plan.deletedSize)) 240 + if plan.affectedPeriod != "" { 241 + fmt.Printf(" Time period: %s\n", plan.affectedPeriod) 242 + } 243 + fmt.Printf("\n") 244 + 245 + // Show sample of deleted bundles 246 + if len(plan.toDelete) > 0 { 247 + fmt.Printf(" Bundles to delete:\n") 248 + displayCount := min(10, len(plan.toDelete)) 249 + for i := 0; i < displayCount; i++ { 250 + meta := plan.toDelete[i] 251 + fmt.Printf(" • %06d (%s, %s, %s ops)\n", 101 252 meta.BundleNumber, 102 253 meta.CreatedAt.Format("2006-01-02 15:04"), 103 - formatBytes(meta.CompressedSize)) 104 - } else if i == 10 { 105 - fmt.Printf(" ... (%d more bundles)\n", len(toDelete)-15) 254 + formatBytes(meta.CompressedSize), 255 + formatNumber(meta.OperationCount)) 256 + } 257 + if len(plan.toDelete) > displayCount { 258 + fmt.Printf(" ... and %d more\n", len(plan.toDelete)-displayCount) 106 259 } 260 + fmt.Printf("\n") 261 + } 262 + 263 + // Show impacts 264 + fmt.Printf("⚠️ Additional Impacts\n") 265 + if plan.hasMempool { 266 + fmt.Printf(" • Mempool will be cleared\n") 267 + } 268 + if plan.hasDIDIndex { 269 + fmt.Printf(" • DID index will need rebuilding\n") 270 + } 271 + if len(plan.toKeep) == 0 { 272 + fmt.Printf(" • Repository will be EMPTY after rollback\n") 107 273 } 108 274 fmt.Printf("\n") 275 + } 109 276 110 - // Calculate what will be deleted 111 - var deletedOps int 112 - var deletedSize int64 113 - for _, meta := range toDelete { 114 - deletedOps += meta.OperationCount 115 - deletedSize += meta.CompressedSize 277 + // confirmRollback prompts user for confirmation 278 + func confirmRollback(keepFiles bool) bool { 279 + if keepFiles { 280 + fmt.Printf("Type 'rollback-index' to confirm (index-only mode): ") 281 + } else { 282 + fmt.Printf("⚠️ This will permanently DELETE data!\n") 283 + fmt.Printf("Type 'rollback' to confirm: ") 116 284 } 117 285 118 - fmt.Printf("⚠️ WARNING: This will delete:\n") 119 - fmt.Printf(" • %s operations\n", formatNumber(deletedOps)) 120 - fmt.Printf(" • %s of data\n", formatBytes(deletedSize)) 121 - fmt.Printf(" • Mempool will be cleared\n") 122 - if didStats := mgr.GetDIDIndexStats(); didStats["exists"].(bool) { 123 - fmt.Printf(" • DID index will be invalidated\n") 286 + var response string 287 + fmt.Scanln(&response) 288 + 289 + expectedResponse := "rollback" 290 + if keepFiles { 291 + expectedResponse = "rollback-index" 124 292 } 125 - fmt.Printf("\n") 293 + 294 + return strings.TrimSpace(response) == expectedResponse 295 + } 126 296 127 - if !*force { 128 - fmt.Printf("Type 'rollback' to confirm: ") 129 - var response string 130 - fmt.Scanln(&response) 131 - if strings.TrimSpace(response) != "rollback" { 132 - fmt.Println("Cancelled") 133 - return nil 297 + // performRollback executes the rollback operations 298 + func performRollback(ctx context.Context, mgr BundleManager, dir string, plan *rollbackPlan, opts rollbackOptions) error { 299 + totalSteps := 4 300 + currentStep := 0 301 + 302 + // Step 1: Delete bundle files (or skip if keepFiles) 303 + currentStep++ 304 + if !opts.keepFiles { 305 + fmt.Printf("[%d/%d] Deleting bundle files...\n", currentStep, totalSteps) 306 + if err := deleteBundleFiles(dir, plan.toDelete, opts.verbose); err != nil { 307 + return fmt.Errorf("failed to delete bundles: %w", err) 134 308 } 309 + fmt.Printf(" ✓ Deleted %d file(s)\n\n", len(plan.toDelete)) 310 + } else { 311 + fmt.Printf("[%d/%d] Skipping file deletion (--keep-files)...\n", currentStep, totalSteps) 312 + fmt.Printf(" ℹ Bundle files remain on disk\n\n") 135 313 } 136 314 137 - fmt.Printf("\n") 315 + // Step 2: Clear mempool 316 + currentStep++ 317 + fmt.Printf("[%d/%d] Clearing mempool...\n", currentStep, totalSteps) 318 + if err := clearMempool(mgr, plan.hasMempool); err != nil { 319 + return err 320 + } 321 + fmt.Printf(" ✓ Mempool cleared\n\n") 138 322 139 - // Step 1: Delete bundle files 140 - fmt.Printf("[1/4] Deleting bundle files...\n") 323 + // Step 3: Update index 324 + currentStep++ 325 + fmt.Printf("[%d/%d] Updating bundle index...\n", currentStep, totalSteps) 326 + if err := updateBundleIndex(mgr, plan); err != nil { 327 + return err 328 + } 329 + fmt.Printf(" ✓ Index updated (%d bundles)\n\n", len(plan.toKeep)) 330 + 331 + // Step 4: Handle DID index 332 + currentStep++ 333 + fmt.Printf("[%d/%d] DID index...\n", currentStep, totalSteps) 334 + if err := handleDIDIndex(ctx, mgr, plan, opts); err != nil { 335 + return err 336 + } 337 + 338 + return nil 339 + } 340 + 341 + // deleteBundleFiles removes bundle files from disk 342 + func deleteBundleFiles(dir string, bundles []*bundleindex.BundleMetadata, verbose bool) error { 141 343 deletedCount := 0 142 - for _, meta := range toDelete { 344 + failedCount := 0 345 + var firstError error 346 + 347 + var progress *ui.ProgressBar 348 + if !verbose && len(bundles) > 10 { 349 + progress = ui.NewProgressBar(len(bundles)) 350 + } 351 + 352 + for i, meta := range bundles { 143 353 bundlePath := filepath.Join(dir, fmt.Sprintf("%06d.jsonl.zst", meta.BundleNumber)) 354 + 144 355 if err := os.Remove(bundlePath); err != nil { 145 356 if !os.IsNotExist(err) { 146 - fmt.Printf(" ⚠️ Failed to delete %06d: %v\n", meta.BundleNumber, err) 357 + failedCount++ 358 + if firstError == nil { 359 + firstError = err 360 + } 361 + if verbose { 362 + fmt.Printf(" ⚠️ Failed to delete %06d: %v\n", meta.BundleNumber, err) 363 + } 147 364 continue 148 365 } 149 366 } 367 + 150 368 deletedCount++ 369 + 370 + if verbose { 371 + fmt.Printf(" ✓ Deleted %06d (%s)\n", 372 + meta.BundleNumber, 373 + formatBytes(meta.CompressedSize)) 374 + } 375 + 376 + if progress != nil { 377 + progress.Set(i + 1) 378 + } 151 379 } 152 - fmt.Printf(" ✓ Deleted %d bundle file(s)\n\n", deletedCount) 380 + 381 + if progress != nil { 382 + progress.Finish() 383 + } 384 + 385 + if failedCount > 0 { 386 + return fmt.Errorf("failed to delete %d bundles (deleted %d successfully): %w", 387 + failedCount, deletedCount, firstError) 388 + } 389 + 390 + return nil 391 + } 153 392 154 - // Step 2: Clear mempool 155 - fmt.Printf("[2/4] Clearing mempool...\n") 393 + // clearMempool clears the mempool 394 + func clearMempool(mgr BundleManager, hasMempool bool) error { 395 + if !hasMempool { 396 + return nil 397 + } 398 + 156 399 if err := mgr.ClearMempool(); err != nil { 157 - fmt.Printf(" ⚠️ Warning: failed to clear mempool: %v\n", err) 158 - } else { 159 - fmt.Printf(" ✓ Mempool cleared\n\n") 400 + return fmt.Errorf("failed to clear mempool: %w", err) 160 401 } 161 402 162 - // Step 3: Update bundle index 163 - fmt.Printf("[3/4] Updating bundle index...\n") 164 - index.Rebuild(toKeep) 403 + return nil 404 + } 405 + 406 + // updateBundleIndex updates the index to reflect rollback 407 + func updateBundleIndex(mgr BundleManager, plan *rollbackPlan) error { 408 + index := mgr.GetIndex() 409 + index.Rebuild(plan.toKeep) 410 + 165 411 if err := mgr.SaveIndex(); err != nil { 166 412 return fmt.Errorf("failed to save index: %w", err) 167 413 } 168 - fmt.Printf(" ✓ Index updated (%d bundles)\n\n", len(toKeep)) 414 + 415 + return nil 416 + } 417 + 418 + // handleDIDIndex manages DID index state after rollback 419 + func handleDIDIndex(ctx context.Context, mgr BundleManager, plan *rollbackPlan, opts rollbackOptions) error { 420 + if !plan.hasDIDIndex { 421 + fmt.Printf(" (no DID index)\n") 422 + return nil 423 + } 424 + 425 + if opts.rebuildDIDIndex { 426 + fmt.Printf(" Rebuilding DID index...\n") 427 + 428 + bundleCount := len(plan.toKeep) 429 + if bundleCount == 0 { 430 + fmt.Printf(" ℹ No bundles to index\n") 431 + return nil 432 + } 433 + 434 + var progress *ui.ProgressBar 435 + if !opts.verbose { 436 + progress = ui.NewProgressBar(bundleCount) 437 + } 169 438 170 - // Step 4: Handle DID index 171 - fmt.Printf("[4/4] DID index...\n") 172 - didStats := mgr.GetDIDIndexStats() 173 - if didStats["exists"].(bool) { 174 - if *rebuildDIDIndex { 175 - fmt.Printf(" Rebuilding DID index...\n") 176 - ctx := context.Background() 177 - if err := mgr.BuildDIDIndex(ctx, func(current, total int) { 178 - if current%100 == 0 || current == total { 179 - fmt.Printf(" Progress: %d/%d (%.1f%%) \r", 180 - current, total, float64(current)/float64(total)*100) 181 - } 182 - }); err != nil { 183 - return fmt.Errorf("failed to rebuild DID index: %w", err) 439 + err := mgr.BuildDIDIndex(ctx, func(current, total int) { 440 + if progress != nil { 441 + progress.Set(current) 442 + } else if current%100 == 0 || current == total { 443 + fmt.Printf(" Progress: %d/%d (%.1f%%) \r", 444 + current, total, float64(current)/float64(total)*100) 184 445 } 185 - fmt.Printf("\n ✓ DID index rebuilt\n") 186 - } else { 187 - // Just mark as needing rebuild 188 - fmt.Printf(" ⚠️ DID index is out of date\n") 189 - fmt.Printf(" Run: plcbundle index build\n") 446 + }) 447 + 448 + if progress != nil { 449 + progress.Finish() 190 450 } 451 + 452 + if err != nil { 453 + return fmt.Errorf("failed to rebuild DID index: %w", err) 454 + } 455 + 456 + fmt.Printf(" ✓ DID index rebuilt (%d bundles)\n", bundleCount) 191 457 } else { 192 - fmt.Printf(" (no DID index)\n") 458 + // Get DID index config to show which bundle it's at 459 + didIndex := mgr.GetDIDIndex() 460 + config := didIndex.GetConfig() 461 + 462 + fmt.Printf(" ⚠️ DID index is out of date\n") 463 + if config.LastBundle > plan.targetBundle { 464 + fmt.Printf(" Index has bundle %06d, but repository now at %06d\n", 465 + config.LastBundle, plan.targetBundle) 466 + } 467 + fmt.Printf(" Run: plcbundle index build\n") 193 468 } 194 469 470 + return nil 471 + } 472 + 473 + // displayRollbackSuccess shows the final state 474 + func displayRollbackSuccess(plan *rollbackPlan, opts rollbackOptions) { 195 475 fmt.Printf("\n") 196 - fmt.Printf("╔════════════════════════════════════════════════════════╗\n") 197 - fmt.Printf("║ ROLLBACK COMPLETE ║\n") 198 - fmt.Printf("╚════════════════════════════════════════════════════════╝\n\n") 476 + fmt.Printf("╔════════════════════════════════════════════════════════════════╗\n") 477 + fmt.Printf("║ ROLLBACK COMPLETE ║\n") 478 + fmt.Printf("╚════════════════════════════════════════════════════════════════╝\n\n") 199 479 200 - if len(toKeep) > 0 { 201 - lastBundle := toKeep[len(toKeep)-1] 202 - fmt.Printf(" New state:\n") 203 - fmt.Printf(" Bundles: %d (%06d - %06d)\n", 204 - len(toKeep), toKeep[0].BundleNumber, lastBundle.BundleNumber) 205 - fmt.Printf(" Chain head: %s\n", lastBundle.Hash[:16]+"...") 206 - fmt.Printf(" Last update: %s\n", lastBundle.EndTime.Format("2006-01-02 15:04:05")) 480 + if len(plan.toKeep) > 0 { 481 + lastBundle := plan.toKeep[len(plan.toKeep)-1] 482 + firstBundle := plan.toKeep[0] 483 + 484 + fmt.Printf("📦 New State\n") 485 + fmt.Printf(" Bundles: %d (%06d → %06d)\n", 486 + len(plan.toKeep), 487 + firstBundle.BundleNumber, 488 + lastBundle.BundleNumber) 489 + 490 + totalOps := 0 491 + totalSize := int64(0) 492 + for _, meta := range plan.toKeep { 493 + totalOps += meta.OperationCount 494 + totalSize += meta.CompressedSize 495 + } 496 + 497 + fmt.Printf(" Operations: %s\n", formatNumber(totalOps)) 498 + fmt.Printf(" Data size: %s\n", formatBytes(totalSize)) 499 + fmt.Printf(" Chain head: %s...\n", lastBundle.Hash[:16]) 500 + fmt.Printf(" Last updated: %s\n", lastBundle.EndTime.Format("2006-01-02 15:04:05")) 207 501 } else { 208 - fmt.Printf(" State: empty (all bundles removed)\n") 502 + fmt.Printf("📦 New State\n") 503 + fmt.Printf(" Repository: EMPTY (all bundles removed)\n") 504 + if opts.keepFiles { 505 + fmt.Printf(" Note: Bundle files remain on disk\n") 506 + } 209 507 } 210 508 211 509 fmt.Printf("\n") 510 + 511 + // Show what was removed 512 + fmt.Printf("🗑️ Removed\n") 513 + fmt.Printf(" Bundles: %d\n", len(plan.toDelete)) 514 + fmt.Printf(" Operations: %s\n", formatNumber(plan.deletedOps)) 515 + fmt.Printf(" Data freed: %s\n", formatBytes(plan.deletedSize)) 516 + 517 + if opts.keepFiles { 518 + fmt.Printf(" Files: kept on disk\n") 519 + } 520 + 521 + fmt.Printf("\n") 522 + 523 + // Next steps 524 + if !opts.rebuildDIDIndex && plan.hasDIDIndex { 525 + fmt.Printf("💡 Next Steps\n") 526 + fmt.Printf(" DID index is out of date. Rebuild with:\n") 527 + fmt.Printf(" plcbundle index build\n\n") 528 + } 529 + } 530 + 531 + // Validation helpers 532 + 533 + // validateRollbackSafety performs additional safety checks 534 + func validateRollbackSafety(mgr BundleManager, plan *rollbackPlan) error { 535 + // Check for chain integrity issues 536 + if len(plan.toKeep) > 1 { 537 + // Verify the target bundle exists and has valid hash 538 + lastKeep := plan.toKeep[len(plan.toKeep)-1] 539 + if lastKeep.Hash == "" { 540 + return fmt.Errorf("target bundle %06d has no chain hash - may be corrupted", 541 + lastKeep.BundleNumber) 542 + } 543 + } 544 + 212 545 return nil 213 546 }
+2 -2
cmd/plcbundle/main.go
··· 49 49 /*cmd.AddCommand(commands.NewPullCommand()) 50 50 cmd.AddCommand(commands.NewExportCommand())*/ 51 51 cmd.AddCommand(commands.NewStreamCommand()) 52 - /*cmd.AddCommand(commands.NewGetCommand()) 53 - cmd.AddCommand(commands.NewRollbackCommand())*/ 52 + //cmd.AddCommand(commands.NewGetCommand()) 53 + cmd.AddCommand(commands.NewRollbackCommand()) 54 54 55 55 // Status & info (root level) 56 56 cmd.AddCommand(commands.NewStatusCommand())