1package cli
2
3import (
4 "archive/tar"
5 "compress/gzip"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "time"
11
12 "github.com/spf13/cobra"
13)
14
15var bundleCmd = &cobra.Command{
16 Use: "bundle [output]",
17 Short: "Pack the repository into a single portable archive",
18 Long: `Create a .arche-bundle tar+gzip archive containing every database and pack
19file needed to fully restore or clone the repository. The archive includes:
20
21 store.db - the main object and metadata database
22 issues.db - the issue-tracker database (if present)
23 config.toml - repository configuration
24 packs/ - all pack files
25
26The output file defaults to <repo-name>-<date>.arche-bundle when not given.`,
27 Args: cobra.MaximumNArgs(1),
28 RunE: func(cmd *cobra.Command, args []string) error {
29 r := openRepo()
30 defer r.Close()
31
32 archeDir := r.ArcheDir()
33
34 var out string
35 if len(args) > 0 {
36 out = args[0]
37 } else {
38 name := filepath.Base(r.Root)
39 dateStr := time.Now().Format("20060102")
40 out = fmt.Sprintf("%s-%s.arche-bundle", name, dateStr)
41 }
42
43 f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
44 if err != nil {
45 return fmt.Errorf("create bundle file: %w", err)
46 }
47 defer f.Close()
48
49 gz := gzip.NewWriter(f)
50 tw := tar.NewWriter(gz)
51
52 type entry struct{ name, path string }
53 candidates := []entry{
54 {"store.db", filepath.Join(archeDir, "store.db")},
55 {"issues.db", filepath.Join(archeDir, "issues.db")},
56 {"config.toml", filepath.Join(archeDir, "config.toml")},
57 }
58
59 for _, e := range candidates {
60 if err := addFileToTar(tw, e.name, e.path); err != nil {
61 if !os.IsNotExist(err) {
62 tw.Close()
63 gz.Close()
64 os.Remove(out)
65 return fmt.Errorf("bundle %s: %w", e.name, err)
66 }
67 }
68 }
69
70 packsPath := filepath.Join(archeDir, "packs")
71 if info, err := os.Stat(packsPath); err == nil && info.IsDir() {
72 if err := addDirToTar(tw, "packs", packsPath); err != nil {
73 tw.Close()
74 gz.Close()
75 os.Remove(out)
76 return fmt.Errorf("bundle packs: %w", err)
77 }
78 }
79
80 if err := tw.Close(); err != nil {
81 gz.Close()
82 os.Remove(out)
83 return err
84 }
85 if err := gz.Close(); err != nil {
86 os.Remove(out)
87 return err
88 }
89
90 fi, _ := f.Stat()
91 size := int64(0)
92 if fi != nil {
93 size = fi.Size()
94 }
95 fmt.Printf("Created %s (%s)\n", out, humanBytes(size))
96 return nil
97 },
98}
99
100func addFileToTar(tw *tar.Writer, name, path string) error {
101 f, err := os.Open(path)
102 if err != nil {
103 return err
104 }
105 defer f.Close()
106
107 fi, err := f.Stat()
108 if err != nil {
109 return err
110 }
111
112 hdr := &tar.Header{
113 Name: name,
114 Size: fi.Size(),
115 Mode: int64(fi.Mode()),
116 ModTime: fi.ModTime(),
117 }
118 if err := tw.WriteHeader(hdr); err != nil {
119 return err
120 }
121 _, err = io.Copy(tw, f)
122 return err
123}
124
125func addDirToTar(tw *tar.Writer, prefix, dir string) error {
126 return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
127 if err != nil {
128 return err
129 }
130 if info.IsDir() {
131 return nil
132 }
133 rel, err := filepath.Rel(dir, path)
134 if err != nil {
135 return err
136 }
137 return addFileToTar(tw, filepath.Join(prefix, rel), path)
138 })
139}
140
141func humanBytes(n int64) string {
142 const unit = 1024
143 if n < unit {
144 return fmt.Sprintf("%d B", n)
145 }
146 div, exp := int64(unit), 0
147 for n2 := n / unit; n2 >= unit; n2 /= unit {
148 div *= unit
149 exp++
150 }
151 return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
152}