package sync import ( "bufio" "database/sql" "fmt" "os" "os/exec" "path/filepath" "slices" "strconv" "git.meatbag.se/vlv/shell-history/internal/model" ) func Run(d *sql.DB, args []string) error { if len(args) < 1 { fmt.Fprintln(os.Stderr, "usage: shist sync DIR") os.Exit(1) } gitDir := args[0] if err := export(d, gitDir); err != nil { return fmt.Errorf("export: %w", err) } if err := gitSync(gitDir); err != nil { return fmt.Errorf("git sync: %w", err) } if err := importAll(d, gitDir); err != nil { return fmt.Errorf("import: %w", err) } fmt.Println("Sync complete.") return nil } func export(d *sql.DB, gitDir string) error { var lastID int64 err := d.QueryRow(`SELECT COALESCE(value, '0') FROM sync_meta WHERE key = 'last_export_id'`).Scan(&lastID) if err == sql.ErrNoRows { lastID = 0 } else if err != nil { return err } rows, err := d.Query( `SELECT id, timestamp, hostname, working_dir, command FROM history WHERE id > ? ORDER BY id`, lastID, ) if err != nil { return err } defer rows.Close() // Group entries by date for file output files := make(map[string][]string) // date -> lines var maxID int64 var count int for rows.Next() { var id int64 var e model.Entry if err := rows.Scan(&id, &e.Timestamp, &e.Hostname, &e.WorkingDir, &e.Command); err != nil { return err } // Extract date from timestamp (first 10 chars of ISO 8601) date := e.Timestamp[:10] files[date] = append(files[date], e.Format()) if id > maxID { maxID = id } count++ } if err := rows.Err(); err != nil { return err } if count == 0 { fmt.Println("No new entries to export.") return nil } // Write/append to log files, then sort each for date, lines := range files { path := filepath.Join(gitDir, "zsh-history-"+date+".log") // Read existing lines existing, _ := readLines(path) all := append(existing, lines...) slices.Sort(all) all = slices.Compact(all) if err := writeLines(path, all); err != nil { return fmt.Errorf("write %s: %w", path, err) } } // Update last_export_id _, err = d.Exec( `INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_export_id', ?)`, strconv.FormatInt(maxID, 10), ) if err != nil { return err } fmt.Printf("Exported %d entries.\n", count) return nil } func gitSync(gitDir string) error { git := func(args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = gitDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // Stage log files matches, _ := filepath.Glob(filepath.Join(gitDir, "zsh-history-*.log")) if len(matches) > 0 { addArgs := []string{"add"} for _, m := range matches { addArgs = append(addArgs, filepath.Base(m)) } // Also add .gitattributes if present if _, err := os.Stat(filepath.Join(gitDir, ".gitattributes")); err == nil { addArgs = append(addArgs, ".gitattributes") } git(addArgs...) } // Commit if there are staged changes cmd := exec.Command("git", "diff", "--cached", "--quiet") cmd.Dir = gitDir if err := cmd.Run(); err != nil { // There are staged changes hostname, _ := os.Hostname() git("commit", "-m", hostname+": update history") } // Pull if err := git("pull", "--no-edit", "--no-rebase"); err != nil { return fmt.Errorf("git pull: %w", err) } // Re-sort log files after merge needsCommit := false logFiles, _ := filepath.Glob(filepath.Join(gitDir, "zsh-history-*.log")) for _, f := range logFiles { if sortFile(f) { needsCommit = true } } if needsCommit { addArgs := []string{"add"} for _, f := range logFiles { addArgs = append(addArgs, filepath.Base(f)) } git(addArgs...) hostname, _ := os.Hostname() git("commit", "-m", hostname+": sort after merge") } // Push return git("push") } func importAll(d *sql.DB, gitDir string) error { files, err := filepath.Glob(filepath.Join(gitDir, "zsh-history-*.log")) if err != nil { return err } var totalInserted int for _, f := range files { inserted, err := importFile(d, f) if err != nil { return fmt.Errorf("import %s: %w", f, err) } totalInserted += inserted } // Update last_export_id to current max var maxID int64 d.QueryRow("SELECT COALESCE(MAX(id), 0) FROM history").Scan(&maxID) d.Exec( `INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_export_id', ?)`, strconv.FormatInt(maxID, 10), ) if totalInserted > 0 { fmt.Printf("Imported %d new entries from git.\n", totalInserted) } return nil } func importFile(d *sql.DB, path string) (int, error) { f, err := os.Open(path) if err != nil { return 0, err } defer f.Close() tx, err := d.Begin() if err != nil { return 0, err } defer tx.Rollback() stmt, err := tx.Prepare( `INSERT OR IGNORE INTO history (timestamp, hostname, working_dir, command) VALUES (?, ?, ?, ?)`, ) if err != nil { return 0, err } defer stmt.Close() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) var inserted, batch int for scanner.Scan() { entry, err := model.ParseLine(scanner.Text()) if err != nil { continue } res, err := stmt.Exec(entry.Timestamp, entry.Hostname, entry.WorkingDir, entry.Command) if err != nil { return inserted, err } n, _ := res.RowsAffected() inserted += int(n) batch++ if batch >= 1000 { if err := tx.Commit(); err != nil { return inserted, err } tx, err = d.Begin() if err != nil { return inserted, err } stmt, err = tx.Prepare( `INSERT OR IGNORE INTO history (timestamp, hostname, working_dir, command) VALUES (?, ?, ?, ?)`, ) if err != nil { return inserted, err } batch = 0 } } if err := scanner.Err(); err != nil { return inserted, err } return inserted, tx.Commit() } func readLines(path string) ([]string, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var lines []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) for scanner.Scan() { if line := scanner.Text(); line != "" { lines = append(lines, line) } } return lines, scanner.Err() } func writeLines(path string, lines []string) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() w := bufio.NewWriter(f) for _, line := range lines { w.WriteString(line) w.WriteByte('\n') } return w.Flush() } func sortFile(path string) bool { lines, err := readLines(path) if err != nil || len(lines) == 0 { return false } if slices.IsSorted(lines) { return false } slices.Sort(lines) lines = slices.Compact(lines) writeLines(path, lines) return true }