larc r22

607 lines · 13.2 KB Raw
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "time"
10
11 "github.com/spf13/cobra"
12
13 "larc.wejust.rest/larc/internal/cli"
14 "larc.wejust.rest/larc/internal/core"
15 "larc.wejust.rest/larc/internal/hash"
16 "larc.wejust.rest/larc/internal/repo"
17 "larc.wejust.rest/larc/internal/status"
18 )
19
20 /* larc - version control system CLI
21 * Commands: init, add, commit, status, log, branch, checkout */
22
23 var (
24 version = "0.1.0"
25 )
26
27 func main() {
28 rootCmd := &cobra.Command{
29 Use: "larc",
30 Short: "yet another vcs",
31 Long: `yet another vcs`,
32 }
33
34 /* global flags */
35 rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
36
37 /* commands */
38 rootCmd.AddCommand(initCmd())
39 rootCmd.AddCommand(statusCmd())
40 rootCmd.AddCommand(addCmd())
41 rootCmd.AddCommand(commitCmd())
42 rootCmd.AddCommand(logCmd())
43 rootCmd.AddCommand(smartlogCmd())
44 rootCmd.AddCommand(branchCmd())
45 rootCmd.AddCommand(versionCmd())
46
47 /* remote commands */
48 rootCmd.AddCommand(cli.CloneCmd())
49 rootCmd.AddCommand(cli.PullCmd())
50 rootCmd.AddCommand(cli.PushCmd())
51 rootCmd.AddCommand(cli.RemoteCmd())
52
53 /* mount commands */
54 rootCmd.AddCommand(cli.MountCmd())
55 rootCmd.AddCommand(cli.UnmountCmd())
56
57 /* convert commands */
58 rootCmd.AddCommand(cli.ConvertCmd())
59
60 if err := rootCmd.Execute(); err != nil {
61 os.Exit(1)
62 }
63 }
64
65 func initCmd() *cobra.Command {
66 return &cobra.Command{
67 Use: "init [path]",
68 Short: "Initialize a new repository",
69 Args: cobra.MaximumNArgs(1),
70 RunE: func(cmd *cobra.Command, args []string) error {
71 path := "."
72 if len(args) > 0 {
73 path = args[0]
74 }
75
76 absPath, err := filepath.Abs(path)
77 if err != nil {
78 return err
79 }
80
81 r, err := repo.Init(absPath)
82 if err != nil {
83 return fmt.Errorf("init failed: %w", err)
84 }
85 defer r.Close()
86
87 fmt.Printf("Initialized empty larc repository in %s/.larc\n", absPath)
88 return nil
89 },
90 }
91 }
92
93 func statusCmd() *cobra.Command {
94 return &cobra.Command{
95 Use: "status",
96 Short: "Show working tree status",
97 RunE: func(cmd *cobra.Command, args []string) error {
98 wd, _ := os.Getwd()
99 repoRoot, err := repo.FindRepoRoot(wd)
100 if err != nil {
101 return fmt.Errorf("not a larc repository")
102 }
103
104 r, err := repo.Open(repoRoot)
105 if err != nil {
106 return err
107 }
108 defer r.Close()
109
110 branch, _ := r.CurrentBranch()
111 rev, _ := r.CurrentRevision()
112
113 fmt.Printf("On branch %s at r%d\n", branch, rev)
114
115 /* scan for changes */
116 scanner := status.NewScanner(r)
117 changes, err := scanner.Scan()
118 if err != nil {
119 return fmt.Errorf("scan failed: %w", err)
120 }
121
122 if len(changes) == 0 {
123 fmt.Println("nothing to commit, working tree clean")
124 return nil
125 }
126
127 /* group changes */
128 var added, modified, deleted []string
129 for _, c := range changes {
130 switch c.Type {
131 case status.Added:
132 added = append(added, c.Path)
133 case status.Modified:
134 modified = append(modified, c.Path)
135 case status.Deleted:
136 deleted = append(deleted, c.Path)
137 }
138 }
139
140 if len(added) > 0 {
141 fmt.Println("\nUntracked files:")
142 for _, p := range added {
143 fmt.Printf(" %s\n", p)
144 }
145 }
146
147 if len(modified) > 0 {
148 fmt.Println("\nModified:")
149 for _, p := range modified {
150 fmt.Printf(" %s\n", p)
151 }
152 }
153
154 if len(deleted) > 0 {
155 fmt.Println("\nDeleted:")
156 for _, p := range deleted {
157 fmt.Printf(" %s\n", p)
158 }
159 }
160
161 return nil
162 },
163 }
164 }
165
166 func addCmd() *cobra.Command {
167 cmd := &cobra.Command{
168 Use: "add <files...>",
169 Short: "Add files to staging area",
170 Args: cobra.MinimumNArgs(1),
171 RunE: func(cmd *cobra.Command, args []string) error {
172 wd, _ := os.Getwd()
173 repoRoot, err := repo.FindRepoRoot(wd)
174 if err != nil {
175 return fmt.Errorf("not a larc repository")
176 }
177
178 r, err := repo.Open(repoRoot)
179 if err != nil {
180 return err
181 }
182 defer r.Close()
183
184 all, _ := cmd.Flags().GetBool("all")
185
186 verbose, _ := cmd.Flags().GetBool("verbose")
187
188 if all || (len(args) == 1 && args[0] == ".") {
189 /* add all files */
190 scanner := status.NewScanner(r)
191 scanner.Verbose = verbose
192 changes, err := scanner.Scan()
193 if err != nil {
194 return err
195 }
196
197 for i, c := range changes {
198 if verbose {
199 fmt.Printf("[%d/%d] %s (%s)\n", i+1, len(changes), c.Path, c.Type)
200 }
201 if c.Type != status.Deleted {
202 absPath := filepath.Join(repoRoot, c.Path)
203 h, size, err := r.Blobs.WriteFile(absPath)
204 if err != nil {
205 fmt.Printf("warning: failed to add %s: %v\n", c.Path, err)
206 continue
207 }
208 scanner.Stage(c.Path, h, size)
209 } else {
210 scanner.StageDelete(c.Path)
211 }
212 }
213
214 if err := scanner.SaveIndex(); err != nil {
215 return err
216 }
217
218 fmt.Printf("Added %d file(s)\n", len(changes))
219 return nil
220 }
221
222 /* add specific files */
223 scanner := status.NewScanner(r)
224 count := 0
225
226 for _, arg := range args {
227 /* expand glob patterns */
228 matches, err := filepath.Glob(arg)
229 if err != nil || len(matches) == 0 {
230 matches = []string{arg}
231 }
232
233 for _, path := range matches {
234 absPath, err := filepath.Abs(path)
235 if err != nil {
236 continue
237 }
238
239 relPath, err := filepath.Rel(repoRoot, absPath)
240 if err != nil || strings.HasPrefix(relPath, "..") {
241 fmt.Printf("warning: %s is outside repository\n", path)
242 continue
243 }
244
245 info, err := os.Stat(absPath)
246 if err != nil {
247 scanner.StageDelete(relPath)
248 count++
249 continue
250 }
251
252 if info.IsDir() {
253 /* add directory recursively */
254 filepath.Walk(absPath, func(p string, info os.FileInfo, err error) error {
255 if err != nil || info.IsDir() {
256 return nil
257 }
258 if strings.Contains(p, "/.larc/") {
259 return nil
260 }
261
262 rel, _ := filepath.Rel(repoRoot, p)
263 h, size, err := r.Blobs.WriteFile(p)
264 if err != nil {
265 return nil
266 }
267 scanner.Stage(rel, h, size)
268 count++
269 return nil
270 })
271 } else {
272 h, size, err := r.Blobs.WriteFile(absPath)
273 if err != nil {
274 fmt.Printf("warning: failed to add %s: %v\n", path, err)
275 continue
276 }
277 scanner.Stage(relPath, h, size)
278 count++
279 }
280 }
281 }
282
283 if err := scanner.SaveIndex(); err != nil {
284 return err
285 }
286
287 fmt.Printf("Added %d file(s)\n", count)
288 return nil
289 },
290 }
291
292 cmd.Flags().BoolP("all", "A", false, "add all files")
293 cmd.Flags().BoolP("verbose", "v", false, "verbose output")
294 return cmd
295 }
296
297 func commitCmd() *cobra.Command {
298 cmd := &cobra.Command{
299 Use: "commit",
300 Short: "Commit staged changes",
301 RunE: func(cmd *cobra.Command, args []string) error {
302 wd, _ := os.Getwd()
303 repoRoot, err := repo.FindRepoRoot(wd)
304 if err != nil {
305 return fmt.Errorf("not a larc repository")
306 }
307
308 r, err := repo.Open(repoRoot)
309 if err != nil {
310 return err
311 }
312 defer r.Close()
313
314 message, _ := cmd.Flags().GetString("message")
315 if message == "" {
316 return fmt.Errorf("commit message required (-m)")
317 }
318
319 author, _ := cmd.Flags().GetString("author")
320 if author == "" {
321 author = os.Getenv("USER")
322 if author == "" {
323 author = "unknown"
324 }
325 }
326
327 /* get staged entries */
328 scanner := status.NewScanner(r)
329 entries, err := scanner.GetStagedEntries()
330 if err != nil {
331 return err
332 }
333
334 if len(entries) == 0 {
335 fmt.Println("nothing to commit")
336 return nil
337 }
338
339 /* create commit */
340 rev, err := r.Commit(author, message, entries)
341 if err != nil {
342 return fmt.Errorf("commit failed: %w", err)
343 }
344
345 /* clear staging */
346 scanner.ClearStaging()
347 scanner.SaveIndex()
348
349 branch, _ := r.CurrentBranch()
350 fmt.Printf("[%s r%d] %s\n", branch, rev.Number, message)
351 fmt.Printf(" %d file(s) changed\n", len(entries))
352
353 return nil
354 },
355 }
356
357 cmd.Flags().StringP("message", "m", "", "commit message")
358 cmd.Flags().StringP("author", "a", "", "author name")
359 return cmd
360 }
361
362 func logCmd() *cobra.Command {
363 cmd := &cobra.Command{
364 Use: "log",
365 Short: "Show commit history",
366 RunE: func(cmd *cobra.Command, args []string) error {
367 wd, _ := os.Getwd()
368 repoRoot, err := repo.FindRepoRoot(wd)
369 if err != nil {
370 return fmt.Errorf("not a larc repository")
371 }
372
373 r, err := repo.Open(repoRoot)
374 if err != nil {
375 return err
376 }
377 defer r.Close()
378
379 n, _ := cmd.Flags().GetInt("n")
380 branch, _ := cmd.Flags().GetString("branch")
381
382 revs, err := r.Meta.ListRevisions(branch, n, 0)
383 if err != nil {
384 return err
385 }
386
387 if len(revs) == 0 {
388 fmt.Println("No commits yet")
389 return nil
390 }
391
392 for _, rev := range revs {
393 t := time.Unix(rev.Timestamp, 0)
394 fmt.Printf("\x1b[33mr%d\x1b[0m (%s) - %s\n", rev.Number, rev.Branch, rev.Author)
395 fmt.Printf(" %s\n", t.Format("2006-01-02 15:04:05"))
396 fmt.Printf(" %s\n\n", rev.Message)
397 }
398
399 return nil
400 },
401 }
402
403 cmd.Flags().IntP("n", "n", 10, "number of commits to show")
404 cmd.Flags().StringP("branch", "b", "", "filter by branch")
405 return cmd
406 }
407
408 func smartlogCmd() *cobra.Command {
409 cmd := &cobra.Command{
410 Use: "smartlog",
411 Aliases: []string{"sl"},
412 Short: "Show commit graph",
413 RunE: func(cmd *cobra.Command, args []string) error {
414 wd, _ := os.Getwd()
415 repoRoot, err := repo.FindRepoRoot(wd)
416 if err != nil {
417 return fmt.Errorf("not a larc repository")
418 }
419
420 r, err := repo.Open(repoRoot)
421 if err != nil {
422 return err
423 }
424 defer r.Close()
425
426 n, _ := cmd.Flags().GetInt("n")
427 currentRev, _ := r.CurrentRevision()
428 currentBranch, _ := r.CurrentBranch()
429
430 /* get branches for labels */
431 branches, _ := r.Meta.ListBranches()
432 branchHeads := make(map[int64][]string)
433 for _, b := range branches {
434 branchHeads[b.HeadRev] = append(branchHeads[b.HeadRev], b.Name)
435 }
436
437 /* get revisions */
438 revs, err := r.Meta.ListRevisions("", n, 0)
439 if err != nil {
440 return err
441 }
442
443 if len(revs) == 0 {
444 fmt.Println("No commits yet")
445 return nil
446 }
447
448 /* print smartlog */
449 for i, rev := range revs {
450 isHead := rev.Number == currentRev
451 t := time.Unix(rev.Timestamp, 0)
452 timeStr := formatRelativeTime(t)
453
454 /* graph character */
455 var graphChar string
456 if isHead {
457 graphChar = "\x1b[33m@\x1b[0m"
458 } else if i == 0 {
459 graphChar = "o"
460 } else {
461 graphChar = "o"
462 }
463
464 /* build branch labels */
465 var labels []string
466 if brs, ok := branchHeads[rev.Number]; ok {
467 for _, br := range brs {
468 if br == currentBranch && isHead {
469 labels = append(labels, br)
470 } else {
471 labels = append(labels, "remote/"+br)
472 }
473 }
474 }
475 labelStr := ""
476 if len(labels) > 0 {
477 labelStr = " \x1b[35m" + strings.Join(labels, " ") + "\x1b[0m"
478 }
479
480 /* print line */
481 hash := fmt.Sprintf("r%d", rev.Number)
482 fmt.Printf("%s \x1b[33m%s\x1b[0m %s \x1b[36m%s\x1b[0m%s\n",
483 graphChar, hash, timeStr, rev.Author, labelStr)
484
485 /* connector */
486 if i < len(revs)-1 {
487 fmt.Println("\x1b[90m│\x1b[0m " + rev.Message)
488 fmt.Println("\x1b[90m│\x1b[0m")
489 } else {
490 fmt.Println("\x1b[90m~\x1b[0m")
491 }
492 }
493
494 return nil
495 },
496 }
497
498 cmd.Flags().IntP("n", "n", 10, "number of commits to show")
499 return cmd
500 }
501
502 func formatRelativeTime(t time.Time) string {
503 now := time.Now()
504 diff := now.Sub(t)
505
506 switch {
507 case diff < time.Minute:
508 return "just now"
509 case diff < time.Hour:
510 m := int(diff.Minutes())
511 if m == 1 {
512 return "1 minute ago"
513 }
514 return fmt.Sprintf("%d minutes ago", m)
515 case diff < 24*time.Hour:
516 if now.Day() == t.Day() {
517 return "Today at " + t.Format("15:04")
518 }
519 return "Yesterday at " + t.Format("15:04")
520 case diff < 48*time.Hour:
521 return "Yesterday at " + t.Format("15:04")
522 case diff < 7*24*time.Hour:
523 return t.Format("Monday at 15:04")
524 default:
525 return t.Format("2006-01-02")
526 }
527 }
528
529 func branchCmd() *cobra.Command {
530 cmd := &cobra.Command{
531 Use: "branch [name]",
532 Short: "List or create branches",
533 RunE: func(cmd *cobra.Command, args []string) error {
534 wd, _ := os.Getwd()
535 repoRoot, err := repo.FindRepoRoot(wd)
536 if err != nil {
537 return fmt.Errorf("not a larc repository")
538 }
539
540 r, err := repo.Open(repoRoot)
541 if err != nil {
542 return err
543 }
544 defer r.Close()
545
546 if len(args) == 0 {
547 /* list branches */
548 branches, err := r.Meta.ListBranches()
549 if err != nil {
550 return err
551 }
552
553 currentBranch, _ := r.CurrentBranch()
554
555 for _, b := range branches {
556 marker := " "
557 if b.Name == currentBranch {
558 marker = "*"
559 }
560 fmt.Printf("%s %s (r%d)\n", marker, b.Name, b.HeadRev)
561 }
562 return nil
563 }
564
565 /* create new branch */
566 name := args[0]
567 if !core.BranchNameValid(name) {
568 return fmt.Errorf("invalid branch name: %s", name)
569 }
570
571 currentRev, _ := r.CurrentRevision()
572
573 branch := &core.Branch{
574 Name: name,
575 HeadRev: currentRev,
576 CreatedAt: time.Now().Unix(),
577 CreatedFrom: currentRev,
578 }
579
580 if err := r.Meta.CreateBranch(branch); err != nil {
581 return fmt.Errorf("failed to create branch: %w", err)
582 }
583
584 fmt.Printf("Created branch '%s' at r%d\n", name, currentRev)
585 return nil
586 },
587 }
588
589 return cmd
590 }
591
592 func versionCmd() *cobra.Command {
593 return &cobra.Command{
594 Use: "version",
595 Short: "Show version information",
596 Run: func(cmd *cobra.Command, args []string) {
597 fmt.Printf("larc version %s\n", version)
598 fmt.Printf("go version: %s\n", runtime.Version())
599 fmt.Printf("hash algorithm: xxhash64\n")
600 fmt.Printf("compression: zstd\n")
601 },
602 }
603 }
604
605 /* NOTE(kroot): helper to suppress unused import warning */
606 var _ = hash.Bytes
607