[DEPRECATED] Go implementation of plcbundle
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}