shell-history/internal/sync/sync.go

327 lines
7 KiB
Go

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 {
var gitDir string
if len(args) > 0 {
gitDir = args[0]
// Store git_dir for future use
d.Exec(`INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('git_dir', ?)`, gitDir)
} else {
// Try to read stored git_dir
err := d.QueryRow(`SELECT value FROM sync_meta WHERE key = 'git_dir'`).Scan(&gitDir)
if err != nil {
fmt.Fprintln(os.Stderr, "usage: verbatim sync [DIR]")
fmt.Fprintln(os.Stderr, " DIR must be provided on first run")
os.Exit(1)
}
}
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)
}
// Record last sync time
d.Exec(`INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync_time', datetime('now'))`)
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
}