From d62beed23f50a930ce0bbb40bf84a608bb333932 Mon Sep 17 00:00:00 2001 From: Viktor Varland Date: Mon, 16 Feb 2026 19:32:38 +0100 Subject: [PATCH] feat: refactor to go tool --- README.md | 72 +++++++-- go.mod | 18 +++ go.sum | 47 ++++++ internal/add/add.go | 33 ++++ internal/db/db.go | 62 +++++++ internal/importer/import.go | 123 ++++++++++++++ internal/model/entry.go | 49 ++++++ internal/search/search.go | 66 ++++++++ internal/stats/stats.go | 89 ++++++++++ internal/sync/sync.go | 314 ++++++++++++++++++++++++++++++++++++ main.go | 77 +++++++++ 11 files changed, 938 insertions(+), 12 deletions(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/add/add.go create mode 100644 internal/db/db.go create mode 100644 internal/importer/import.go create mode 100644 internal/model/entry.go create mode 100644 internal/search/search.go create mode 100644 internal/stats/stats.go create mode 100644 internal/sync/sync.go create mode 100644 main.go diff --git a/README.md b/README.md index 12742b6..b4efeaa 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,88 @@ # shell-history -## zsh +Shell history stored in SQLite, synced via git. + +## Install ``` cd $HOME/dev/vlv git clone git.meatbag.se:vlv/shell-history.git shell-history mkdir histdb + +cd shell-history +go build -o shist . +sudo install shist /usr/local/bin/ ``` +## Migrate from log files + +``` +shist import ~/dev/vlv/histdb/ +``` + +## zsh + `~/.zshrc`: -``` +```zsh autoload -Uz compinit promptinit add-zsh-hook histdb_dir="$HOME/dev/vlv/histdb" -histcmd_dir="$HOME/dev/vlv/shell-history" _add_history() { if test "$(id -u)" -ne 0; then local cmd=$(fc -ln -1) cmd=${cmd//$'\n'/\\n} - echo "$(date --utc --iso-8601=seconds) $(hostname) $(pwd) $cmd" >> $histdb_dir/zsh-history-$(date "+%Y-%m-%d").log; + shist add \ + --timestamp "$(date --utc --iso-8601=seconds)" \ + --hostname "$(hostname)" \ + --dir "$(pwd)" \ + -- "$cmd" fi } add-zsh-hook precmd _add_history -hsup() { - () { - source $histcmd_dir/sync.sh "$histdb_dir" - } -} +hsup() { shist sync "$histdb_dir" } hs() { - () { - source $histcmd_dir/search.sh "$histdb_dir" "$@" - } + local copy_cmd= + case "$XDG_SESSION_TYPE" in + x11) copy_cmd="xsel --clipboard" ;; + wayland) copy_cmd="wl-copy --trim-newline" ;; + *) echo "Session type not detected."; return ;; + esac + + local entry="$( + shist search "$@" | \ + fzf --ansi --disabled --query "${*:-}" \ + --bind "start:reload:shist search {q}" \ + --bind "change:reload:sleep 0.1; shist search {q} || true" \ + --delimiter ' ' + )" + + local entry_cmd="$(<<<$entry awk -F' ' '{ print substr($0, index($0, $4)) }')" + entry_cmd="${entry_cmd//\\n/$'\n'}" + + if [[ "$entry" ]]; then + eval $copy_cmd <<< "$entry_cmd" >/dev/null + echo "Copied to clipboard." >&2 + fi } +``` + +## Usage ``` +shist [--db PATH] add --timestamp T --hostname H --dir D -- COMMAND... +shist [--db PATH] search [--host H] [--dir D] [--after T] [--before T] [--limit N] [QUERY...] +shist [--db PATH] sync DIR +shist [--db PATH] import DIR +shist [--db PATH] stats +``` + +Default `--db`: `~/.local/share/shist/history.db` + +## Legacy + +The old shell scripts (`search.sh`, `sync.sh`) are kept for backward compatibility +during migration. They continue to work with the log files in the git repo. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8f3f9e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module git.meatbag.se/vlv/shell-history + +go 1.26 + +require modernc.org/sqlite v1.37.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/sys v0.31.0 // indirect + modernc.org/libc v1.62.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.9.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e944b01 --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= +modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= +modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= +modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/add/add.go b/internal/add/add.go new file mode 100644 index 0000000..8f1027d --- /dev/null +++ b/internal/add/add.go @@ -0,0 +1,33 @@ +package add + +import ( + "database/sql" + "flag" + "fmt" + "os" + "strings" +) + +func Run(d *sql.DB, args []string) error { + fs := flag.NewFlagSet("add", flag.ExitOnError) + timestamp := fs.String("timestamp", "", "ISO 8601 timestamp") + hostname := fs.String("hostname", "", "hostname") + dir := fs.String("dir", "", "working directory") + fs.Parse(args) + + if *timestamp == "" || *hostname == "" || *dir == "" { + fmt.Fprintln(os.Stderr, "usage: shist add --timestamp T --hostname H --dir D -- COMMAND...") + os.Exit(1) + } + + command := strings.Join(fs.Args(), " ") + if command == "" { + return nil + } + + _, err := d.Exec( + `INSERT OR IGNORE INTO history (timestamp, hostname, working_dir, command) VALUES (?, ?, ?, ?)`, + *timestamp, *hostname, *dir, command, + ) + return err +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..ffdbb0b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,62 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func Open(path string) (*sql.DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + for _, pragma := range []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + } { + if _, err := db.Exec(pragma); err != nil { + db.Close() + return nil, fmt.Errorf("set %s: %w", pragma, err) + } + } + + if err := migrate(db); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return db, nil +} + +func migrate(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + hostname TEXT NOT NULL, + working_dir TEXT NOT NULL, + command TEXT NOT NULL, + UNIQUE(timestamp, hostname, working_dir, command) + ); + + CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_history_hostname ON history(hostname); + CREATE INDEX IF NOT EXISTS idx_history_working_dir ON history(working_dir); + + CREATE TABLE IF NOT EXISTS sync_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `) + return err +} diff --git a/internal/importer/import.go b/internal/importer/import.go new file mode 100644 index 0000000..47ee741 --- /dev/null +++ b/internal/importer/import.go @@ -0,0 +1,123 @@ +package importer + +import ( + "bufio" + "database/sql" + "fmt" + "os" + "path/filepath" + + "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 import DIR") + os.Exit(1) + } + + return importDir(d, args[0]) +} + +func importDir(d *sql.DB, dir string) error { + files, err := filepath.Glob(filepath.Join(dir, "zsh-history-*.log")) + if err != nil { + return err + } + + var totalRead, totalSkipped, totalInserted int + + for _, f := range files { + read, skipped, inserted, err := importFile(d, f) + if err != nil { + return fmt.Errorf("import %s: %w", f, err) + } + totalRead += read + totalSkipped += skipped + totalInserted += inserted + } + + // Set last_export_id to max id so we don't re-export on first sync + var maxID int64 + err = d.QueryRow("SELECT COALESCE(MAX(id), 0) FROM history").Scan(&maxID) + if err != nil { + return err + } + _, err = d.Exec( + `INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_export_id', ?)`, + fmt.Sprintf("%d", maxID), + ) + if err != nil { + return err + } + + fmt.Printf("Import complete: %d lines read, %d skipped, %d inserted, %d duplicates\n", + totalRead, totalSkipped, totalInserted, totalRead-totalSkipped-totalInserted) + return nil +} + +func importFile(d *sql.DB, path string) (read, skipped, inserted int, err error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, 0, err + } + defer f.Close() + + tx, err := d.Begin() + if err != nil { + return 0, 0, 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, 0, 0, err + } + defer stmt.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + + batch := 0 + for scanner.Scan() { + read++ + entry, err := model.ParseLine(scanner.Text()) + if err != nil { + skipped++ + continue + } + + res, err := stmt.Exec(entry.Timestamp, entry.Hostname, entry.WorkingDir, entry.Command) + if err != nil { + return read, skipped, inserted, err + } + n, _ := res.RowsAffected() + inserted += int(n) + + batch++ + if batch >= 1000 { + if err := tx.Commit(); err != nil { + return read, skipped, inserted, err + } + tx, err = d.Begin() + if err != nil { + return read, skipped, inserted, err + } + stmt, err = tx.Prepare( + `INSERT OR IGNORE INTO history (timestamp, hostname, working_dir, command) VALUES (?, ?, ?, ?)`, + ) + if err != nil { + return read, skipped, inserted, err + } + batch = 0 + } + } + + if err := scanner.Err(); err != nil { + return read, skipped, inserted, err + } + + return read, skipped, inserted, tx.Commit() +} diff --git a/internal/model/entry.go b/internal/model/entry.go new file mode 100644 index 0000000..1c8de7b --- /dev/null +++ b/internal/model/entry.go @@ -0,0 +1,49 @@ +package model + +import ( + "fmt" + "strings" + "time" +) + +type Entry struct { + Timestamp string + Hostname string + WorkingDir string + Command string +} + +// ParseLine parses a log line in "TIMESTAMP HOSTNAME DIR COMMAND..." format. +func ParseLine(line string) (Entry, error) { + line = strings.TrimSpace(line) + if line == "" { + return Entry{}, fmt.Errorf("empty line") + } + + // Split into at most 4 parts: timestamp, hostname, dir, command(rest) + parts := strings.SplitN(line, " ", 4) + if len(parts) < 4 { + return Entry{}, fmt.Errorf("malformed line: %q", line) + } + + ts := parts[0] + if _, err := time.Parse(time.RFC3339, ts); err != nil { + return Entry{}, fmt.Errorf("invalid timestamp %q: %w", ts, err) + } + + if parts[1] == "" || parts[2] == "" || parts[3] == "" { + return Entry{}, fmt.Errorf("empty field in line: %q", line) + } + + return Entry{ + Timestamp: ts, + Hostname: parts[1], + WorkingDir: parts[2], + Command: parts[3], + }, nil +} + +// Format returns the entry in log format: "TIMESTAMP HOSTNAME DIR COMMAND" +func (e Entry) Format() string { + return fmt.Sprintf("%s %s %s %s", e.Timestamp, e.Hostname, e.WorkingDir, e.Command) +} diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 0000000..00c47aa --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,66 @@ +package search + +import ( + "database/sql" + "flag" + "fmt" + "strings" +) + +func Run(d *sql.DB, args []string) error { + fs := flag.NewFlagSet("search", flag.ExitOnError) + host := fs.String("host", "", "filter by hostname") + dir := fs.String("dir", "", "filter by working directory") + after := fs.String("after", "", "filter entries after timestamp") + before := fs.String("before", "", "filter entries before timestamp") + limit := fs.Int("limit", 10000, "max results") + fs.Parse(args) + + query := strings.Join(fs.Args(), " ") + + var where []string + var params []any + + if query != "" { + where = append(where, "command LIKE ?") + params = append(params, "%"+query+"%") + } + if *host != "" { + where = append(where, "hostname = ?") + params = append(params, *host) + } + if *dir != "" { + where = append(where, "working_dir = ?") + params = append(params, *dir) + } + if *after != "" { + where = append(where, "timestamp > ?") + params = append(params, *after) + } + if *before != "" { + where = append(where, "timestamp < ?") + params = append(params, *before) + } + + q := "SELECT timestamp, hostname, working_dir, command FROM history" + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + q += " ORDER BY timestamp DESC LIMIT ?" + params = append(params, *limit) + + rows, err := d.Query(q, params...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ts, h, dir, cmd string + if err := rows.Scan(&ts, &h, &dir, &cmd); err != nil { + return err + } + fmt.Printf("%s %s %s %s\n", ts, h, dir, cmd) + } + return rows.Err() +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..9a3ec79 --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,89 @@ +package stats + +import ( + "database/sql" + "fmt" +) + +func Run(d *sql.DB) error { + var total int + d.QueryRow("SELECT COUNT(*) FROM history").Scan(&total) + fmt.Printf("Total commands: %d\n\n", total) + + fmt.Println("Commands per host:") + rows, err := d.Query("SELECT hostname, COUNT(*) as cnt FROM history GROUP BY hostname ORDER BY cnt DESC") + if err != nil { + return err + } + for rows.Next() { + var host string + var cnt int + rows.Scan(&host, &cnt) + fmt.Printf(" %-30s %d\n", host, cnt) + } + rows.Close() + + fmt.Println("\nTop 20 commands:") + rows, err = d.Query(` + SELECT + CASE INSTR(command, ' ') + WHEN 0 THEN command + ELSE SUBSTR(command, 1, INSTR(command, ' ') - 1) + END as cmd, + COUNT(*) as cnt + FROM history + GROUP BY cmd + ORDER BY cnt DESC + LIMIT 20 + `) + if err != nil { + return err + } + for rows.Next() { + var cmd string + var cnt int + rows.Scan(&cmd, &cnt) + fmt.Printf(" %-30s %d\n", cmd, cnt) + } + rows.Close() + + fmt.Println("\nCommands per month:") + rows, err = d.Query(` + SELECT SUBSTR(timestamp, 1, 7) as month, COUNT(*) as cnt + FROM history + GROUP BY month + ORDER BY month DESC + LIMIT 24 + `) + if err != nil { + return err + } + for rows.Next() { + var month string + var cnt int + rows.Scan(&month, &cnt) + fmt.Printf(" %-10s %d\n", month, cnt) + } + rows.Close() + + fmt.Println("\nTop 10 directories:") + rows, err = d.Query(` + SELECT working_dir, COUNT(*) as cnt + FROM history + GROUP BY working_dir + ORDER BY cnt DESC + LIMIT 10 + `) + if err != nil { + return err + } + for rows.Next() { + var dir string + var cnt int + rows.Scan(&dir, &cnt) + fmt.Printf(" %-50s %d\n", dir, cnt) + } + rows.Close() + + return nil +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 0000000..bdaaf71 --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,314 @@ +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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cc6afa6 --- /dev/null +++ b/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "git.meatbag.se/vlv/shell-history/internal/add" + "git.meatbag.se/vlv/shell-history/internal/db" + "git.meatbag.se/vlv/shell-history/internal/importer" + "git.meatbag.se/vlv/shell-history/internal/search" + "git.meatbag.se/vlv/shell-history/internal/stats" + "git.meatbag.se/vlv/shell-history/internal/sync" +) + +func main() { + global := flag.NewFlagSet("shist", flag.ExitOnError) + dbPath := global.String("db", defaultDBPath(), "path to database") + global.Usage = usage + global.Parse(os.Args[1:]) + + args := global.Args() + if len(args) < 1 { + usage() + } + + cmd := args[0] + rest := args[1:] + + d, err := db.Open(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "shist: %v\n", err) + os.Exit(1) + } + defer d.Close() + + switch cmd { + case "add": + err = add.Run(d, rest) + case "search": + err = search.Run(d, rest) + case "sync": + err = sync.Run(d, rest) + case "import": + err = importer.Run(d, rest) + case "stats": + err = stats.Run(d) + default: + usage() + } + + if err != nil { + fmt.Fprintf(os.Stderr, "shist %s: %v\n", cmd, err) + os.Exit(1) + } +} + +func defaultDBPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "history.db" + } + return filepath.Join(home, ".local", "share", "shist", "history.db") +} + +func usage() { + fmt.Fprintln(os.Stderr, `usage: shist [--db PATH] [flags] + +commands: + add record a command + search search history + sync export, git sync, import + import one-time import from log files + stats show statistics`) + os.Exit(1) +}