315 lines
6.5 KiB
Go
315 lines
6.5 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 {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: verbatim 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
|
|
}
|