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