arche / internal/cli/cmd_bundle.go

commit 154431fd
  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}