feat: refactor to go tool
This commit is contained in:
parent
0e319399db
commit
d62beed23f
72
README.md
72
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.
|
||||
|
|
|
|||
18
go.mod
Normal file
18
go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
47
go.sum
Normal file
47
go.sum
Normal file
|
|
@ -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=
|
||||
33
internal/add/add.go
Normal file
33
internal/add/add.go
Normal file
|
|
@ -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
|
||||
}
|
||||
62
internal/db/db.go
Normal file
62
internal/db/db.go
Normal file
|
|
@ -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
|
||||
}
|
||||
123
internal/importer/import.go
Normal file
123
internal/importer/import.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
49
internal/model/entry.go
Normal file
49
internal/model/entry.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
66
internal/search/search.go
Normal file
66
internal/search/search.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
89
internal/stats/stats.go
Normal file
89
internal/stats/stats.go
Normal file
|
|
@ -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
|
||||
}
|
||||
314
internal/sync/sync.go
Normal file
314
internal/sync/sync.go
Normal file
|
|
@ -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
|
||||
}
|
||||
77
main.go
Normal file
77
main.go
Normal file
|
|
@ -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] <command> [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)
|
||||
}
|
||||
Loading…
Reference in a new issue