Skip to content

Commit 8725c27

Browse files
committed
v1.1.0: huge improvements
- better summary display - better exit code handling - added proper cli args - improved goroutine launching to have minimal waiting time after robocopy is actually done (~0.01 s) - fix a hanging bug - added lipgloss styling to output, highlight important stuff - improved exit code "reasoning" - formatting and minor refactoring
1 parent 199e90a commit 8725c27

5 files changed

Lines changed: 300 additions & 141 deletions

File tree

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ module rbcp
33
go 1.22.3
44

55
require (
6+
github.com/alexflint/go-arg v1.5.1
67
github.com/charmbracelet/bubbles v0.20.0
78
github.com/charmbracelet/bubbletea v1.3.4
89
github.com/charmbracelet/lipgloss v1.0.0
10+
github.com/charmbracelet/log v0.4.0
911
)
1012

1113
require (
14+
github.com/alexflint/go-scalar v1.2.0 // indirect
1215
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1316
github.com/charmbracelet/harmonica v0.2.0 // indirect
14-
github.com/charmbracelet/log v0.4.0 // indirect
1517
github.com/charmbracelet/x/ansi v0.8.0 // indirect
1618
github.com/charmbracelet/x/term v0.2.1 // indirect
1719
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y=
2+
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
3+
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
4+
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
15
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
26
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
37
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
@@ -14,6 +18,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
1418
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
1519
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
1620
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
21+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
22+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1723
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
1824
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
1925
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -32,9 +38,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
3238
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
3339
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
3440
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
41+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
42+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3543
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
3644
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
3745
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
46+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
47+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
48+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
3849
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
3950
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
4051
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
@@ -45,3 +56,5 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
4556
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
4657
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
4758
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
59+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
60+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

rbcp.go

Lines changed: 155 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,128 +4,211 @@ import (
44
"fmt"
55
"os"
66
"os/exec"
7-
"strings"
7+
"strconv"
88
"time"
99

10+
"github.com/alexflint/go-arg"
1011
"github.com/charmbracelet/bubbles/progress"
1112
tea "github.com/charmbracelet/bubbletea"
1213
"github.com/charmbracelet/log"
1314
)
1415

1516
// # Version information
1617
const (
17-
ProgramName = "compact-robocopy"
18-
Version = "1.0.0"
19-
BuildDate = "2025-03-10 18:58:31"
20-
AuthorLogin = "plutonium-239"
18+
ProgramName = "rbcp"
19+
Version = "1.1.0"
20+
BuildDate = "2025-04-17"
21+
Author = "plutonium-239"
2122
)
2223

23-
// FileStats represents statistics for a category of files
24-
type FileStats struct {
25-
Dirs int
26-
Files int
27-
Bytes int64
24+
var p *tea.Program
25+
26+
type Args struct {
27+
Src string `arg:"positional, required"`
28+
Dest string `arg:"positional, required"`
29+
Mir bool `arg:"-m" help:"Convenience argument to specify /MIR to robocopy"`
30+
List bool `arg:"-l" help:"Only list files that would be copied. Similar to a 'dry-run' "`
31+
PreserveExitCode bool `arg:"-p,--preserve-exitcode" help:"Always return the error code given by robocopy. By default, exit with code 0 on success and passthrough on copy failures."`
32+
Insane bool `help:"Don't set sane defaults (currently sets #retries to 2 and timeout between them to 1 sec."`
33+
OtherArgs []string `arg:"positional" help:"All other arguments are passed directly to robocopy."`
2834
}
2935

30-
// RobocopyStats represents all statistics from a robocopy operation
31-
type RobocopyStats struct {
32-
// Categories of statistics
33-
Total FileStats
34-
Copied FileStats
35-
Skipped FileStats
36-
Mismatch FileStats
37-
Failed FileStats
38-
Extras FileStats
39-
40-
// Speed information
41-
BytesPerSec int64
42-
MegaBytesPerMin float64
43-
44-
// Duration
45-
Duration time.Duration
46-
47-
// Exit code
48-
ExitCode int
36+
func (Args) Description() string {
37+
b := ProgramName + " version " + Version + "\n"
38+
b += "\nrbcp is a compact wrapper around robocopy, aiming to modernize the output while preserving the robustness of this time tested tool."
39+
b += "\nAll other arguments are passed directly to robocopy."
40+
return b
4941
}
5042

51-
var p *tea.Program
43+
func (Args) Version() string {
44+
return ProgramName + " version " + Version
45+
}
5246

53-
func main() {
54-
if len(os.Args) < 3 {
55-
fmt.Printf("%s version %s\n", ProgramName, Version)
56-
fmt.Println("Usage: compact-robocopy [robocopy arguments]")
57-
fmt.Println("All arguments are passed directly to robocopy.")
58-
os.Exit(1)
47+
func (args Args) buildRobocopyArgs() []string {
48+
out := []string{args.Src, args.Dest}
49+
out = append(out, args.OtherArgs...)
50+
if args.Mir {
51+
out = append(out, "/MIR")
5952
}
53+
return out
54+
}
6055

56+
// func parseArgs(arglist []string) ([]string, error) {
57+
// convertedArgs := make([]string, 0)
58+
// in, out := "", ""
59+
// for _, arg := range arglist {
60+
// if strings.HasPrefix(arg, "-") {
61+
// option := strings.TrimPrefix(arg, "-")
62+
// switch option {
63+
// case "-help", "h":
64+
// displayHelp()
65+
// os.Exit(0)
66+
// break
67+
// case "-mir", "m":
68+
// convertedArgs = append(convertedArgs, "/MIR")
69+
// break
70+
// case "-list", "-l":
71+
// convertedArgs = append(convertedArgs, "/L")
72+
// break
73+
// // case
74+
// }
75+
// } else if strings.HasPrefix(arg, "/") {
76+
// convertedArgs = append(convertedArgs, arg)
77+
// } else if in == "" {
78+
// in = arg
79+
// } else if out == "" {
80+
// out = arg
81+
// } else {
82+
// log.Fatal("Unrecognized argument: " + arg)
83+
// return nil, errors.New("unrecognized args passed")
84+
// }
85+
// }
86+
// return append([]string{in, out}, convertedArgs...), nil
87+
// }
88+
89+
func main() {
6190
// Pass all arguments directly to robocopy
62-
args := os.Args[1:]
63-
64-
fmt.Println("Starting compact robocopy...")
65-
fmt.Printf("Arguments: %s\n", strings.Join(args, " "))
66-
fmt.Println()
91+
// args := os.Args[1:]
92+
var args Args
93+
arg.MustParse(&args)
94+
// isHelp := slices.ContainsFunc(args, func(e string) bool {
95+
// match, err := regexp.MatchString(`--help|-h`, e)
96+
// return err == nil && match
97+
// })
98+
// if len(args) < 3 {
99+
// displayHelp()
100+
// os.Exit(1)
101+
// }
102+
// args, err := parseArgs(args)
103+
// if err != nil {
104+
// os.Exit(1)
105+
// }
106+
67107
if env, found := os.LookupEnv("LOGLEVEL"); found {
68108
if lvl, err := log.ParseLevel(env); err == nil {
69109
log.SetLevel(lvl)
70110
}
71111
}
112+
113+
// TODO: parse args and figure out dirs vs real args
114+
// - [ ] ignore args that can't be propagated (/NJH, /NDL, /NP, /BYTES)
115+
// - [x] add sane defaults set (retries, timeouts etc.)
116+
// - [ ] also add custom args such as
117+
// - [ ] --preserve-exitcode, -p
118+
// - [x] --mir, -m as convenience
119+
// - [x] --list, -l
120+
// - [x] --insane to ignore sane defaults
121+
fmt.Println(pathStyle.Render(args.Src, " ──── ", args.Dest))
122+
rbarglist := args.buildRobocopyArgs()
123+
log.Infof("Starting compact robocopy with arguments: %v", rbarglist)
72124
startTime := time.Now()
73-
74-
// Add our output formatting flags
75-
args = append(args, "/NJH", "/NDL", "/NP", "/BYTES")
125+
126+
if !args.List {
127+
// Add our output formatting flags
128+
rbarglist = append(rbarglist, "/NJH", "/NDL", "/NP", "/BYTES")
129+
if !args.Insane {
130+
rbarglist = append(rbarglist, "/R:2", "/W:1")
131+
}
132+
} else {
133+
fmt.Println()
134+
}
76135

77136
totalFiles, totalBytes, err := getTotalCounts(args)
78137
if err != nil {
79138
log.Fatalf("Error getting total counts: %v", err)
80139
}
81-
log.Printf("Total to copy: %d files, %s\n", totalFiles, formatByteValue(totalBytes))
140+
log.Infof("Total to copy: %d files, %s\n", totalFiles, formatByteValue(totalBytes))
82141

142+
// This is so simple but looks so bad
143+
envColumns, ok := os.LookupEnv("COLUMNS")
144+
var initWidth int
145+
if i, err := strconv.Atoi(envColumns); ok && err == nil {
146+
initWidth = i
147+
}
83148

84149
m := model{
85-
progress: progress.New(progress.WithDefaultGradient(), progress.WithSpringOptions(40, 1)),
150+
progress: progress.New(progress.WithDefaultGradient(), progress.WithSpringOptions(40, 1)),
86151
totalFiles: totalFiles,
87152
totalBytes: totalBytes,
153+
totalWidth: initWidth,
88154
}
89155
// Start Bubble Tea
90156
p = tea.NewProgram(m)
91157

92158
// Start the download
93159
var stats RobocopyStats
160+
ended := make(chan bool)
94161
go func() {
95-
// Run robocopy and parse results
96-
stats, err = runRobocopy(args)
97-
if err != nil {
98-
log.Fatalf("Error: %v", err)
162+
if totalBytes > 0 {
163+
if _, err := p.Run(); err != nil {
164+
log.Fatal("error running program:", err)
165+
os.Exit(1)
166+
}
167+
} else {
168+
log.Info("Nothing to copy, skipping progress bar")
99169
}
100-
log.Debugf("Killed")
170+
ended <- true
171+
// log.Warnf("program is over, waiting for summary")
101172
}()
102173

103-
if _, err := p.Run(); err != nil {
104-
fmt.Println("error running program:", err)
105-
os.Exit(1)
174+
// Run robocopy and parse results
175+
robocopyStart := time.Now()
176+
stats, err = runRobocopy(rbarglist)
177+
if err != nil {
178+
log.Fatalf("Error: %v", err)
179+
}
180+
// log.Debugf("Killed")
181+
robocopyEnd := time.Now()
182+
if totalBytes > 0 {
183+
p.Send(tea.Quit())
184+
p.Wait()
106185
}
107-
log.Warnf("program is over, waiting for summary")
108186

187+
<-ended
109188
// Display summary
189+
log.Infof("Robocopy took %v", robocopyEnd.Sub(robocopyStart))
190+
log.Infof("Waited for %v", time.Since(robocopyEnd))
110191
displaySummary(stats)
111192

112193
timeTaken := time.Since(startTime)
113194
log.Infof("Whole program took %v", timeTaken)
114195

115-
// Exit with the same code as robocopy
116-
os.Exit(stats.ExitCode)
196+
if args.PreserveExitCode || stats.ExitCode >= 8 {
197+
// Exit with the same code as robocopy
198+
os.Exit(stats.ExitCode)
199+
}
117200
}
118201

119202
func runRobocopy(args []string) (RobocopyStats, error) {
120203
var stats RobocopyStats
121-
204+
122205
// Start timing
123206
startTime := time.Now()
124207

125208
// Run robocopy and capture output
126209
cmd := exec.Command("robocopy", args...)
127210
output, err := cmd.CombinedOutput()
128-
211+
129212
// Calculate duration
130213
stats.Duration = time.Since(startTime)
131214
stats.ExitCode = cmd.ProcessState.ExitCode()
@@ -143,25 +226,31 @@ func runRobocopy(args []string) (RobocopyStats, error) {
143226
return stats, nil
144227
}
145228

146-
147229
// getTotalCounts runs robocopy in list-only mode to get total files and bytes
148-
func getTotalCounts(args []string) (int, int64, error) {
230+
func getTotalCounts(args Args) (int, int64, error) {
149231
// Clone args and add /L to make it "list only" mode
150-
listArgs := make([]string, len(args))
151-
copy(listArgs, args)
152-
listArgs = append(listArgs, "/L", "/NFL", "/NDL", "/NP", "/NC", "/BYTES")
232+
rbargs := args.buildRobocopyArgs()
233+
listArgs := make([]string, len(rbargs))
234+
copy(listArgs, rbargs)
235+
if !args.List {
236+
listArgs = append(listArgs, "/L", "/NFL", "/NDL", "/NP", "/NC", "/BYTES")
237+
}
153238

154239
cmd := exec.Command("robocopy", listArgs...)
155240
output, err := cmd.CombinedOutput()
156241
if err != nil && cmd.ProcessState.ExitCode() > 16 {
157242
return 0, 0, fmt.Errorf("robocopy failed with exit code %d: %v", cmd.ProcessState.ExitCode(), err)
158243
}
244+
if args.List {
245+
fmt.Print(string(output))
246+
os.Exit(0)
247+
}
159248

160249
var stats RobocopyStats
161250
err = parseRobocopyOutput(string(output), &stats)
162251
if err != nil {
163252
return 0, 0, err
164253
}
165254

166-
return stats.Total.Files, stats.Total.Bytes, nil
167-
}
255+
return stats.Copied.Files, stats.Copied.Bytes, nil
256+
}

0 commit comments

Comments
 (0)