package cli import ( "archive/zip" "bufio" "compress/flate" "io" "io/fs" "os" "os/exec" "path/filepath" "runtime" "time" tea "github.com/charmbracelet/bubbletea" "github.com/hachem-wtf/benson/internal/config" ) func runMerge(m *model) tea.Cmd { m.busy = true ch := make(chan tea.Msg, 256) m.events = ch cfg := m.cfg go mergeWorker(cfg, ch) return tea.Batch(m.spin.Tick, waitForEvent(ch)) } func mergeWorker(cfg *config.Config, ch chan<- tea.Msg) { emit := func(s string) { ch <- streamMsg{s} } done := func(s string) { ch <- streamDoneMsg{s} } if cfg.ServerLocation == "" || cfg.BackupLocation == "" { done(errorStyle.Render("server_location and backup_location must be set in config.")) return } if _, err := os.Stat(serverDir); err != nil { done(errorStyle.Render("No generated files found. Run 'generate' first.")) return } emit(headingStyle.Render("Merging generated server")) if cfg.MergePreCommand != "" { emit(mutedStyle.Render("Running pre-merge command…")) if err := runShellCommand(cfg.MergePreCommand, emit); err != nil { emit(warnStyle.Render(" pre-command exited: " + err.Error())) } } if _, err := os.Stat(cfg.ServerLocation); err == nil { emit(mutedStyle.Render("Backing up " + cfg.ServerLocation + "…")) name, err := createBackup(cfg.ServerLocation, cfg.BackupLocation) if err != nil { done(errorStyle.Render("Failed to create backup: " + err.Error())) return } emit(" " + successStyle.Render("✓ ") + name) } else { emit(mutedStyle.Render("Server location doesn't exist, creating it…")) if err := os.MkdirAll(cfg.ServerLocation, 0o755); err != nil { done(errorStyle.Render("Failed to create server location: " + err.Error())) return } } emit(mutedStyle.Render("Moving generated files into place…")) entries, err := os.ReadDir(serverDir) if err != nil { done(errorStyle.Render("Failed to read generated directory: " + err.Error())) return } for _, e := range entries { src := filepath.Join(serverDir, e.Name()) dst := filepath.Join(cfg.ServerLocation, e.Name()) os.RemoveAll(dst) if err := movePath(src, dst); err != nil { done(errorStyle.Render("Failed to move " + e.Name() + ": " + err.Error())) return } emit(" " + successStyle.Render("moved ") + e.Name()) } os.RemoveAll(serverDir) emit(successStyle.Render("Merge complete.")) if cfg.MergePostCommand != "" { emit(mutedStyle.Render("Running post-merge command…")) if err := runShellCommand(cfg.MergePostCommand, emit); err != nil { emit(warnStyle.Render(" post-command exited: " + err.Error())) } } done("") } func createBackup(sourceDir, backupDir string) (string, error) { if err := os.MkdirAll(backupDir, 0o755); err != nil { return "", err } name := "backup_" + time.Now().Format("2006-01-02_15-04-05") + ".zip" f, err := os.Create(filepath.Join(backupDir, name)) if err != nil { return "", err } defer f.Close() zw := zip.NewWriter(f) zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { return flate.NewWriter(out, flate.BestCompression) }) walkErr := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(sourceDir, path) if err != nil { return err } w, err := zw.Create(filepath.ToSlash(rel)) if err != nil { return err } src, err := os.Open(path) if err != nil { return err } defer src.Close() _, err = io.Copy(w, src) return err }) if walkErr != nil { zw.Close() return "", walkErr } if err := zw.Close(); err != nil { return "", err } return name, nil } func runShellCommand(command string, emit func(string)) error { var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/C", command) } else { cmd = exec.Command("sh", "-c", command) } pr, pw, err := os.Pipe() if err != nil { return err } cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { pw.Close() return err } pw.Close() scanner := bufio.NewScanner(pr) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { emit(mutedStyle.Render(" │ " + scanner.Text())) } return cmd.Wait() } func movePath(src, dst string) error { if err := os.Rename(src, dst); err == nil { return nil } info, err := os.Stat(src) if err != nil { return err } if info.IsDir() { if err := copyDir(src, dst); err != nil { return err } } else if err := copyFile(src, dst, info.Mode()); err != nil { return err } return os.RemoveAll(src) } func copyDir(src, dst string) error { if err := os.MkdirAll(dst, 0o755); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, e := range entries { s := filepath.Join(src, e.Name()) d := filepath.Join(dst, e.Name()) if e.IsDir() { if err := copyDir(s, d); err != nil { return err } continue } info, err := e.Info() if err != nil { return err } if err := copyFile(s, d, info.Mode()); err != nil { return err } } return nil } func copyFile(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err }