[DEPRECATED] Go implementation of plcbundle
at rust-test 427 lines 12 kB view raw
1package commands 2 3import ( 4 "fmt" 5 "io" 6 "os" 7 "os/exec" 8 "strings" 9 "time" 10 11 "github.com/spf13/cobra" 12 "golang.org/x/term" 13 "tangled.org/atscan.net/plcbundle/internal/bundleindex" 14) 15 16func NewLogCommand() *cobra.Command { 17 var ( 18 last int 19 oneline bool 20 noHashes bool 21 reverse bool 22 noPager bool 23 ) 24 25 cmd := &cobra.Command{ 26 Use: "log [flags]", 27 Aliases: []string{"history"}, 28 Short: "Show bundle history", 29 Long: `Show bundle history in chronological order 30 31Displays a log of all bundles in the repository, similar to 'git log'. 32By default shows all bundles in reverse chronological order (newest first) 33with chain hashes and automatically pipes through a pager. 34 35The log shows: 36 • Bundle number and timestamp 37 • Operation and DID counts 38 • Compressed and uncompressed sizes 39 • Chain and content hashes (default, use --no-hashes to hide) 40 • Coverage timespan 41 42Output is automatically piped through 'less' (or $PAGER) when stdout 43is a terminal, just like 'git log'. Use --no-pager to disable.`, 44 45 Example: ` # Show all bundles (newest first, auto-paged) 46 plcbundle log 47 48 # Show last 10 bundles 49 plcbundle log --last 10 50 plcbundle log -n 10 51 52 # One-line format 53 plcbundle log --oneline 54 55 # Hide hashes 56 plcbundle log --no-hashes 57 58 # Oldest first (ascending order) 59 plcbundle log --reverse 60 61 # Disable pager (direct output) 62 plcbundle log --no-pager 63 64 # Combination 65 plcbundle log -n 20 --oneline 66 67 # Using alias 68 plcbundle history -n 5`, 69 70 RunE: func(cmd *cobra.Command, args []string) error { 71 mgr, dir, err := getManager(&ManagerOptions{Cmd: cmd}) 72 if err != nil { 73 return err 74 } 75 defer mgr.Close() 76 77 return showBundleLog(mgr, dir, logOptions{ 78 last: last, 79 oneline: oneline, 80 showHashes: !noHashes, // Invert: hashes ON by default 81 reverse: reverse, 82 noPager: noPager, 83 }) 84 }, 85 } 86 87 // Flags 88 cmd.Flags().IntVarP(&last, "last", "n", 0, "Show only last N bundles (0 = all)") 89 cmd.Flags().BoolVar(&oneline, "oneline", false, "Show one line per bundle (compact)") 90 cmd.Flags().BoolVar(&noHashes, "no-hashes", false, "Hide chain and content hashes") 91 cmd.Flags().BoolVar(&reverse, "reverse", false, "Show oldest first (ascending order)") 92 cmd.Flags().BoolVar(&noPager, "no-pager", false, "Disable pager (output directly)") 93 94 return cmd 95} 96 97type logOptions struct { 98 last int 99 oneline bool 100 showHashes bool 101 reverse bool 102 noPager bool 103} 104 105func showBundleLog(mgr BundleManager, dir string, opts logOptions) error { 106 index := mgr.GetIndex() 107 bundles := index.GetBundles() 108 109 if len(bundles) == 0 { 110 fmt.Printf("No bundles in repository\n") 111 fmt.Printf("Directory: %s\n\n", dir) 112 fmt.Printf("Get started:\n") 113 fmt.Printf(" plcbundle clone <url> Clone from remote\n") 114 fmt.Printf(" plcbundle sync Fetch from PLC directory\n") 115 return nil 116 } 117 118 // Apply limit 119 displayBundles := bundles 120 if opts.last > 0 && opts.last < len(bundles) { 121 displayBundles = bundles[len(bundles)-opts.last:] 122 } 123 124 // Reverse if not --reverse (default is newest first) 125 if !opts.reverse { 126 reversed := make([]*bundleindex.BundleMetadata, len(displayBundles)) 127 for i, b := range displayBundles { 128 reversed[len(displayBundles)-1-i] = b 129 } 130 displayBundles = reversed 131 } 132 133 // Determine output destination (pager or stdout) 134 var output io.WriteCloser 135 var useColor bool 136 137 if !opts.noPager && isTTY(os.Stdout) { 138 // Try to use pager 139 pager, err := startPager() 140 if err == nil { 141 output = pager 142 useColor = true 143 defer pager.Close() 144 } else { 145 output = os.Stdout 146 useColor = isTTY(os.Stdout) 147 } 148 } else { 149 output = os.Stdout 150 useColor = !opts.noPager && isTTY(os.Stdout) 151 } 152 153 // Render to output 154 renderBundleLog(output, dir, index, bundles, displayBundles, opts, useColor) 155 156 return nil 157} 158 159func renderBundleLog(w io.Writer, dir string, index *bundleindex.Index, allBundles, displayBundles []*bundleindex.BundleMetadata, opts logOptions, useColor bool) { 160 // Color codes 161 var ( 162 colorReset = "" 163 colorBundleNum = "" 164 colorHash = "" 165 colorDate = "" 166 colorAge = "" 167 colorSize = "" 168 colorHeader = "" 169 colorDim = "" 170 ) 171 172 if useColor { 173 colorReset = "\033[0m" 174 colorBundleNum = "\033[1;36m" // Bright cyan + bold 175 colorHash = "\033[33m" // Yellow 176 colorDate = "\033[32m" // Green 177 colorAge = "\033[90m" // Dim 178 colorSize = "\033[35m" // Magenta 179 colorHeader = "\033[1;37m" // Bright white + bold 180 colorDim = "\033[2m" // Dim 181 } 182 183 // Header 184 if !opts.oneline { 185 fmt.Fprintf(w, "%sBundle History%s\n", colorHeader, colorReset) 186 fmt.Fprintf(w, "Directory: %s\n", dir) 187 if origin := index.Origin; origin != "" { 188 fmt.Fprintf(w, "Origin: %s\n", origin) 189 } 190 fmt.Fprintf(w, "Total: %d bundles", len(allBundles)) 191 if opts.last > 0 && opts.last < len(allBundles) { 192 fmt.Fprintf(w, " (showing last %d)", opts.last) 193 } 194 fmt.Fprintf(w, "\n\n") 195 } 196 197 // Display bundles 198 for i, meta := range displayBundles { 199 if opts.oneline { 200 displayBundleOneLine(w, meta, opts.showHashes, colorBundleNum, colorHash, colorDate, colorAge, colorSize, colorReset) 201 } else { 202 displayBundleDetailed(w, meta, opts.showHashes, colorBundleNum, colorHash, colorDate, colorAge, colorSize, colorDim, colorReset) 203 204 // Add separator between bundles (except last) 205 if i < len(displayBundles)-1 { 206 fmt.Fprintf(w, "%s────────────────────────────────────────────────────────%s\n\n", 207 colorDim, colorReset) 208 } 209 } 210 } 211 212 // Summary footer 213 if !opts.oneline && len(displayBundles) > 0 { 214 fmt.Fprintf(w, "\n") 215 displayLogSummary(w, allBundles, displayBundles, opts.last, colorHeader, colorReset) 216 } 217} 218 219func displayBundleOneLine(w io.Writer, meta *bundleindex.BundleMetadata, showHashes bool, colorBundle, colorHash, colorDate, colorAge, colorSize, colorReset string) { 220 age := time.Since(meta.EndTime) 221 ageStr := formatDurationShort(age) 222 223 // Build hash preview 224 hashPreview := "" 225 if showHashes && len(meta.Hash) >= 12 { 226 hashPreview = fmt.Sprintf(" %s%s%s", colorHash, meta.Hash[:12], colorReset) 227 } 228 229 fmt.Fprintf(w, "%s%06d%s%s %s%s%s %s%s ago%s %d ops, %d DIDs, %s%s%s\n", 230 colorBundle, meta.BundleNumber, colorReset, 231 hashPreview, 232 colorDate, meta.EndTime.Format("2006-01-02 15:04"), colorReset, 233 colorAge, ageStr, colorReset, 234 meta.OperationCount, 235 meta.DIDCount, 236 colorSize, formatBytes(meta.CompressedSize), colorReset) 237} 238 239func displayBundleDetailed(w io.Writer, meta *bundleindex.BundleMetadata, showHashes bool, colorBundle, colorHash, colorDate, colorAge, colorSize, colorDim, colorReset string) { 240 fmt.Fprintf(w, "%sBundle %06d%s\n", colorBundle, meta.BundleNumber, colorReset) 241 242 // Timestamp and age 243 age := time.Since(meta.EndTime) 244 fmt.Fprintf(w, " Date: %s%s%s %s(%s ago)%s\n", 245 colorDate, meta.EndTime.Format("2006-01-02 15:04:05"), colorReset, 246 colorAge, formatDuration(age), colorReset) 247 248 // Timespan of bundle 249 duration := meta.EndTime.Sub(meta.StartTime) 250 fmt.Fprintf(w, " Timespan: %s → %s (%s)\n", 251 meta.StartTime.Format("15:04:05"), 252 meta.EndTime.Format("15:04:05"), 253 formatDuration(duration)) 254 255 // Operations and DIDs 256 fmt.Fprintf(w, " Operations: %s\n", formatNumber(meta.OperationCount)) 257 fmt.Fprintf(w, " DIDs: %s unique\n", formatNumber(meta.DIDCount)) 258 259 // Sizes 260 ratio := float64(meta.UncompressedSize) / float64(meta.CompressedSize) 261 fmt.Fprintf(w, " Size: %s%s%s → %s (%.2fx compression)\n", 262 colorSize, 263 formatBytes(meta.UncompressedSize), 264 colorReset, 265 formatBytes(meta.CompressedSize), 266 ratio) 267 268 // Hashes (shown by default now) 269 if showHashes { 270 fmt.Fprintf(w, "\n Hashes:\n") 271 fmt.Fprintf(w, " %sChain:%s %s%s%s\n", 272 colorDim, colorReset, 273 colorHash, meta.Hash, colorReset) 274 fmt.Fprintf(w, " %sContent:%s %s%s%s\n", 275 colorDim, colorReset, 276 colorHash, meta.ContentHash, colorReset) 277 if meta.Parent != "" { 278 fmt.Fprintf(w, " %sParent:%s %s%s%s\n", 279 colorDim, colorReset, 280 colorHash, meta.Parent, colorReset) 281 } 282 } 283} 284 285func displayLogSummary(w io.Writer, allBundles, displayedBundles []*bundleindex.BundleMetadata, limit int, colorHeader, colorReset string) { 286 first := displayedBundles[0] 287 last := displayedBundles[len(displayedBundles)-1] 288 289 // Determine order for summary 290 var earliest, latest *bundleindex.BundleMetadata 291 if first.BundleNumber < last.BundleNumber { 292 earliest = first 293 latest = last 294 } else { 295 earliest = last 296 latest = first 297 } 298 299 fmt.Fprintf(w, "%sSummary%s\n", colorHeader, colorReset) 300 fmt.Fprintf(w, "───────\n") 301 302 if limit > 0 && limit < len(allBundles) { 303 fmt.Fprintf(w, " Showing: %d of %d bundles\n", len(displayedBundles), len(allBundles)) 304 } else { 305 fmt.Fprintf(w, " Bundles: %d total\n", len(allBundles)) 306 } 307 308 fmt.Fprintf(w, " Range: %06d → %06d\n", earliest.BundleNumber, latest.BundleNumber) 309 310 // Calculate total stats for displayed bundles 311 totalOps := 0 312 totalSize := int64(0) 313 totalUncompressed := int64(0) 314 for _, meta := range displayedBundles { 315 totalOps += meta.OperationCount 316 totalSize += meta.CompressedSize 317 totalUncompressed += meta.UncompressedSize 318 } 319 320 fmt.Fprintf(w, " Operations: %s\n", formatNumber(totalOps)) 321 fmt.Fprintf(w, " Size: %s (compressed)\n", formatBytes(totalSize)) 322 323 timespan := latest.EndTime.Sub(earliest.StartTime) 324 fmt.Fprintf(w, " Timespan: %s\n", formatDuration(timespan)) 325} 326 327// isTTY checks if the given file is a terminal 328func isTTY(f *os.File) bool { 329 return term.IsTerminal(int(f.Fd())) 330} 331 332// startPager starts a pager process and returns a WriteCloser 333func startPager() (io.WriteCloser, error) { 334 // Check for PAGER environment variable 335 pagerCmd := os.Getenv("PAGER") 336 if pagerCmd == "" { 337 // Default to less with options 338 pagerCmd = "less" 339 } 340 341 // Parse pager command (handle args) 342 parts := strings.Fields(pagerCmd) 343 if len(parts) == 0 { 344 return nil, fmt.Errorf("empty pager command") 345 } 346 347 cmdName := parts[0] 348 cmdArgs := parts[1:] 349 350 // Special handling for less - add color support flags 351 if cmdName == "less" || strings.HasSuffix(cmdName, "/less") { 352 // Add flags if not already present 353 hasR := false 354 for _, arg := range cmdArgs { 355 if strings.Contains(arg, "R") { 356 hasR = true 357 break 358 } 359 } 360 if !hasR { 361 // -R: enable raw control characters (for colors) 362 // -F: quit if output fits on one screen 363 // -X: don't clear screen on exit 364 cmdArgs = append([]string{"-R", "-F", "-X"}, cmdArgs...) 365 } 366 } 367 368 cmd := exec.Command(cmdName, cmdArgs...) 369 cmd.Stdout = os.Stdout 370 cmd.Stderr = os.Stderr 371 372 stdin, err := cmd.StdinPipe() 373 if err != nil { 374 return nil, err 375 } 376 377 if err := cmd.Start(); err != nil { 378 return nil, err 379 } 380 381 // Return a WriteCloser that closes stdin and waits for process 382 return &pagerWriter{ 383 writer: stdin, 384 cmd: cmd, 385 started: true, 386 }, nil 387} 388 389// pagerWriter wraps a pager process 390type pagerWriter struct { 391 writer io.WriteCloser 392 cmd *exec.Cmd 393 started bool 394} 395 396func (pw *pagerWriter) Write(p []byte) (n int, err error) { 397 return pw.writer.Write(p) 398} 399 400func (pw *pagerWriter) Close() error { 401 pw.writer.Close() 402 if pw.started { 403 pw.cmd.Wait() 404 } 405 return nil 406} 407 408// formatDurationShort formats duration in short form 409func formatDurationShort(d time.Duration) string { 410 if d < time.Minute { 411 return fmt.Sprintf("%.0fs", d.Seconds()) 412 } 413 if d < time.Hour { 414 return fmt.Sprintf("%.0fm", d.Minutes()) 415 } 416 if d < 24*time.Hour { 417 return fmt.Sprintf("%.0fh", d.Hours()) 418 } 419 days := d.Hours() / 24 420 if days < 30 { 421 return fmt.Sprintf("%.0fd", days) 422 } 423 if days < 365 { 424 return fmt.Sprintf("%.0fmo", days/30) 425 } 426 return fmt.Sprintf("%.1fy", days/365) 427}