@@ -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
1617const (
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 += "\n rbcp is a compact wrapper around robocopy, aiming to modernize the output while preserving the robustness of this time tested tool."
39+ b += "\n All 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
119202func 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