[DEPRECATED] Go implementation of plcbundle

cmd log

+436 -6
+427
cmd/plcbundle/commands/log.go
··· 1 + package commands 2 + 3 + import ( 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 + 16 + func 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 + 31 + Displays a log of all bundles in the repository, similar to 'git log'. 32 + By default shows all bundles in reverse chronological order (newest first) 33 + with chain hashes and automatically pipes through a pager. 34 + 35 + The 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 + 42 + Output is automatically piped through 'less' (or $PAGER) when stdout 43 + is 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 := getManagerFromCommand(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 + 97 + type logOptions struct { 98 + last int 99 + oneline bool 100 + showHashes bool 101 + reverse bool 102 + noPager bool 103 + } 104 + 105 + func 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 + 159 + func 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, useColor, colorBundleNum, colorHash, colorDate, colorAge, colorSize, colorReset) 201 + } else { 202 + displayBundleDetailed(w, meta, opts.showHashes, useColor, 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, useColor, colorHeader, colorReset) 216 + } 217 + } 218 + 219 + func displayBundleOneLine(w io.Writer, meta *bundleindex.BundleMetadata, showHashes bool, useColor 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 + 239 + func displayBundleDetailed(w io.Writer, meta *bundleindex.BundleMetadata, showHashes bool, useColor 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 + 285 + func displayLogSummary(w io.Writer, allBundles, displayedBundles []*bundleindex.BundleMetadata, limit int, useColor bool, 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 328 + func isTTY(f *os.File) bool { 329 + return term.IsTerminal(int(f.Fd())) 330 + } 331 + 332 + // startPager starts a pager process and returns a WriteCloser 333 + func 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 390 + type pagerWriter struct { 391 + writer io.WriteCloser 392 + cmd *exec.Cmd 393 + started bool 394 + } 395 + 396 + func (pw *pagerWriter) Write(p []byte) (n int, err error) { 397 + return pw.writer.Write(p) 398 + } 399 + 400 + func (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 409 + func 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 + }
+2 -2
cmd/plcbundle/commands/status.go
··· 47 47 bundleCount := stats["bundle_count"].(int) 48 48 49 49 // Header 50 - fmt.Printf("═══════════════════════════════════════════════════════════════\n\n") 51 - fmt.Printf("\n PLC Bundle Repository Status\n") 50 + fmt.Printf("═══════════════════════════════════════════════════════════════\n") 51 + fmt.Printf(" plcbundle Repository Status\n") 52 52 fmt.Printf("═══════════════════════════════════════════════════════════════\n\n") 53 53 54 54 // Location & Origin
+4 -4
cmd/plcbundle/main.go
··· 24 24 Short: "DID PLC Bundle Management Tool", 25 25 Long: `plcbundle - DID PLC Bundle Management Tool 26 26 27 - plcbundle archives AT Protocol's DID PLC Directory operations 27 + Tool for archiving AT Protocol's DID PLC Directory operations 28 28 into immutable, cryptographically-chained bundles of 10,000 29 29 operations each. 30 30 ··· 41 41 cmd.PersistentFlags().StringP("dir", "C", "", "Repository directory (default: current directory)") 42 42 cmd.PersistentFlags().BoolP("verbose", "v", false, "Show detailed output and progress") 43 43 cmd.PersistentFlags().BoolP("quiet", "q", false, "Suppress non-error output") 44 - cmd.PersistentFlags().Bool("json", false, "Output as JSON (where applicable)") 44 + //cmd.PersistentFlags().Bool("json", false, "Output as JSON (where applicable)") 45 45 46 46 // Bundle operations (root level - most common) 47 47 cmd.AddCommand(commands.NewSyncCommand()) ··· 54 54 55 55 // Status & info (root level) 56 56 cmd.AddCommand(commands.NewStatusCommand()) 57 - /*cmd.AddCommand(commands.NewLogCommand()) 58 - cmd.AddCommand(commands.NewGapsCommand())*/ 57 + cmd.AddCommand(commands.NewLogCommand()) 58 + //cmd.AddCommand(commands.NewGapsCommand()) 59 59 cmd.AddCommand(commands.NewVerifyCommand()) 60 60 cmd.AddCommand(commands.NewDiffCommand()) 61 61 /*cmd.AddCommand(commands.NewStatsCommand())
+1
go.mod
··· 12 12 require ( 13 13 github.com/spf13/cobra v1.10.1 14 14 github.com/spf13/pflag v1.0.10 15 + golang.org/x/term v0.36.0 15 16 ) 16 17 17 18 require github.com/inconshreveable/mousetrap v1.1.0 // indirect
+2
go.sum
··· 15 15 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 16 16 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 17 17 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 18 + golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 19 + golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 18 20 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 21 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=