2 Commits

Author SHA1 Message Date
6e4ed9a1cd Implement bash CTRL-R reverse history
Fork customized liner into own repo bash.
Rename -being and -end options to -after and -before.
2021-08-08 11:37:48 +02:00
26ababc93e Begin liner integration 2021-08-08 11:31:41 +02:00
5 changed files with 63 additions and 178 deletions

View File

@ -2,34 +2,15 @@
hs9001 (history search 9001) is an easy, quite simple bash history enhancement. It simply writes all hs9001 (history search 9001) is an easy, quite simple bash history enhancement. It simply writes all
your bash commands into an sqlite database. You can then search this database. your bash commands into an sqlite database. You can then search this database.
It improves over bash's built-in history mechanism as it'll aggregate the shell history of all open shells,
timestamp them and also record additional information such as the directory a command was executed in.
## Usage / Examples
### Search
```
hs [search terms]
```
You can further filter with options like `-cwd`, `-after` and so on...
For a full list, see `-help`.
```
hs -cwd .
```
Lists all commands ever entered in this directory
```
hs -today -cwd . git
```
Lists all git commands in the current directory which have been entered today.
Also, it (by default) replaces bash's built-in CTRL-R mechanism, so hs9001's database will be used instead of bash's limited history files.
When in reverse-search mode, you can only search the history of the current directory by pressing CTRL+A and then "w".
## Install ## Install
### From source
```
go build
#move hs9001 to a PATH location
```
### Debian / Ubuntu ### Debian / Ubuntu
Latest release can be installed using apt Latest release can be installed using apt
``` ```
@ -47,22 +28,31 @@ apk update
apk add hs9001 apk add hs9001
``` ```
### From source
``` ### Setup / Config
go build
#move hs9001 to a PATH location
```
Add this to .bashrc Add this to .bashrc
``` ```
eval "$(hs9001 bash-enable)" eval "$(hs9001 bash-enable)"
``` ```
By default, every system user gets his own database. You can override this by setting the environment variable
for all users that should write to your unified database.
This will also create a `hs`alias so you have to type less in everyday usage.
By default, every system user gets his own database. You can override this by setting the environment variable for all users that should write to your unified database.
``` ```
export HS9001_DB_PATH="/home/db/history.sqlite" export HS9001_DB_PATH="/home/db/history.sqlite"
``` ```
## Usage
### Search
```
hs9001 search [search terms]
```
It is recommended to create an alias for search to make life easier, e. g.:
```
alias searchh='hs9001 search'
```

View File

@ -2,10 +2,8 @@ package main
import ( import (
"database/sql" "database/sql"
"hs9001/liner"
"io" "io"
"log" "log"
"path/filepath"
"strings" "strings"
) )
@ -13,34 +11,13 @@ type history struct {
conn *sql.DB conn *sql.DB
} }
func createSearchOpts(query string, mode int) searchopts { func (h *history) GetHistoryByPrefix(prefix string) (ph []string) {
opts := searchopts{} opts := searchopts{}
o := "DESC" opts.order = "DESC"
opts.order = &o cmdqry := prefix + "%"
lim := 100 opts.command = &cmdqry
opts.limit = &lim
opts.command = &query
switch mode {
case liner.ModeGlobal:
break
case liner.ModeWorkdir:
workdir, err := filepath.Abs(".")
if err != nil {
panic(err)
}
opts.workdir = &workdir
default:
panic("Invalid mode supplied")
}
return opts
}
func (h *history) GetHistoryByPrefix(prefix string, mode int) (ph []string) {
cmdquery := prefix + "%"
opts := createSearchOpts(cmdquery, mode)
results := search(h.conn, opts) results := search(h.conn, opts)
for e := results.Back(); e != nil; e = e.Prev() { for e := results.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*HistoryEntry) entry, ok := e.Value.(*HistoryEntry)
if !ok { if !ok {
log.Panic("Failed to retrieve entries") log.Panic("Failed to retrieve entries")
@ -49,13 +26,13 @@ func (h *history) GetHistoryByPrefix(prefix string, mode int) (ph []string) {
} }
return return
} }
func (h *history) GetHistoryByPattern(pattern string) (ph []string, pos []int) {
func (h *history) GetHistoryByPattern(pattern string, mode int) (ph []string, pos []int) { opts := searchopts{}
cmdquery := "%" + pattern + "%" opts.order = "DESC"
opts := createSearchOpts(cmdquery, mode) cmdqry := "%" + pattern + "%"
opts.command = &cmdqry
results := search(h.conn, opts) results := search(h.conn, opts)
for e := results.Back(); e != nil; e = e.Prev() { for e := results.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*HistoryEntry) entry, ok := e.Value.(*HistoryEntry)
if !ok { if !ok {
log.Panic("Failed to retrieve entries") log.Panic("Failed to retrieve entries")

View File

@ -37,8 +37,8 @@ type HistoryProvider interface {
WriteHistory(w io.Writer) (num int, err error) WriteHistory(w io.Writer) (num int, err error)
AppendHistory(item string) AppendHistory(item string)
ClearHistory() ClearHistory()
GetHistoryByPrefix(prefix string, mode int) (ph []string) GetHistoryByPrefix(prefix string) (ph []string)
GetHistoryByPattern(pattern string, mode int) (ph []string, pos []int) GetHistoryByPattern(pattern string) (ph []string, pos []int)
RLock() RLock()
RUnlock() RUnlock()
} }
@ -95,11 +95,11 @@ func (s *State) AppendHistory(item string) {
func (s *State) ClearHistory() { func (s *State) ClearHistory() {
s.historyProvider.ClearHistory() s.historyProvider.ClearHistory()
} }
func (s *State) getHistoryByPrefix(prefix string, mode int) (ph []string) { func (s *State) getHistoryByPrefix(prefix string) (ph []string) {
return s.historyProvider.GetHistoryByPrefix(prefix, mode) return s.historyProvider.GetHistoryByPrefix(prefix)
} }
func (s *State) getHistoryByPattern(pattern string, mode int) (ph []string, pos []int) { func (s *State) getHistoryByPattern(pattern string) (ph []string, pos []int) {
return s.historyProvider.GetHistoryByPattern(pattern, mode) return s.historyProvider.GetHistoryByPattern(pattern)
} }
// SetHistoryProvider allows you to set a custom provider // SetHistoryProvider allows you to set a custom provider

View File

@ -90,11 +90,6 @@ const (
tabReverse tabReverse
) )
const (
ModeGlobal = iota
ModeWorkdir
)
func (s *State) refresh(prompt []rune, buf []rune, pos int) error { func (s *State) refresh(prompt []rune, buf []rune, pos int) error {
if s.columns == 0 { if s.columns == 0 {
return ErrInternal return ErrInternal
@ -416,26 +411,8 @@ func (s *State) tabComplete(p []rune, line []rune, pos int) ([]rune, int, interf
// reverse intelligent search, implements a bash-like history search. // reverse intelligent search, implements a bash-like history search.
func (s *State) reverseISearch(origLine []rune, origPos int) ([]rune, int, interface{}, error) { func (s *State) reverseISearch(origLine []rune, origPos int) ([]rune, int, interface{}, error) {
modeSelect := false p := "(reverse-i-search)`': "
currentMode := ModeGlobal err := s.refresh([]rune(p), origLine, origPos)
getPrompt := func(arg string) string {
prompt := ""
switch currentMode {
case ModeWorkdir:
prompt = "(reverse:cwd)`%s': "
case ModeGlobal:
prompt = "(reverse:global)`%s': "
default:
panic("Invalid mode")
}
if modeSelect {
prompt = "(select mode)`%s': "
}
return fmt.Sprintf(prompt, arg)
}
err := s.refresh([]rune(getPrompt("")), origLine, origPos)
if err != nil { if err != nil {
return origLine, origPos, rune(esc), err return origLine, origPos, rune(esc), err
} }
@ -449,10 +426,11 @@ func (s *State) reverseISearch(origLine []rune, origPos int) ([]rune, int, inter
getLine := func() ([]rune, []rune, int) { getLine := func() ([]rune, []rune, int) {
search := string(line) search := string(line)
return []rune(getPrompt(search)), []rune(foundLine), foundPos prompt := "(reverse-i-search)`%s': "
return []rune(fmt.Sprintf(prompt, search)), []rune(foundLine), foundPos
} }
history, positions := s.getHistoryByPattern(string(line), currentMode) history, positions := s.getHistoryByPattern(string(line))
historyPos := len(history) - 1 historyPos := len(history) - 1
for { for {
@ -472,8 +450,6 @@ func (s *State) reverseISearch(origLine []rune, origPos int) ([]rune, int, inter
} else { } else {
s.doBeep() s.doBeep()
} }
case ctrlA:
modeSelect = true
case ctrlS: // Search forward case ctrlS: // Search forward
if historyPos < len(history)-1 && historyPos >= 0 { if historyPos < len(history)-1 && historyPos >= 0 {
historyPos++ historyPos++
@ -491,7 +467,7 @@ func (s *State) reverseISearch(origLine []rune, origPos int) ([]rune, int, inter
pos -= n pos -= n
// For each char deleted, display the last matching line of history // For each char deleted, display the last matching line of history
history, positions := s.getHistoryByPattern(string(line), currentMode) history, positions := s.getHistoryByPattern(string(line))
historyPos = len(history) - 1 historyPos = len(history) - 1
if len(history) > 0 { if len(history) > 0 {
foundLine = history[historyPos] foundLine = history[historyPos]
@ -504,28 +480,17 @@ func (s *State) reverseISearch(origLine []rune, origPos int) ([]rune, int, inter
case ctrlG: // Cancel case ctrlG: // Cancel
return origLine, origPos, rune(esc), err return origLine, origPos, rune(esc), err
case tab, cr, lf, ctrlB, ctrlD, ctrlE, ctrlF, ctrlK, case tab, cr, lf, ctrlA, ctrlB, ctrlD, ctrlE, ctrlF, ctrlK,
ctrlL, ctrlN, ctrlO, ctrlP, ctrlQ, ctrlT, ctrlU, ctrlV, ctrlW, ctrlX, ctrlY, ctrlZ: ctrlL, ctrlN, ctrlO, ctrlP, ctrlQ, ctrlT, ctrlU, ctrlV, ctrlW, ctrlX, ctrlY, ctrlZ:
fallthrough fallthrough
case 0, ctrlC, esc, 28, 29, 30, 31: case 0, ctrlC, esc, 28, 29, 30, 31:
return []rune(foundLine), foundPos, next, err return []rune(foundLine), foundPos, next, err
default: default:
if modeSelect {
switch v {
case 'g':
currentMode = ModeGlobal
case 'w':
currentMode = ModeWorkdir
}
modeSelect = false
break
}
line = append(line[:pos], append([]rune{v}, line[pos:]...)...) line = append(line[:pos], append([]rune{v}, line[pos:]...)...)
pos++ pos++
// For each keystroke typed, display the last matching line of history // For each keystroke typed, display the last matching line of history
history, positions = s.getHistoryByPattern(string(line), currentMode) history, positions = s.getHistoryByPattern(string(line))
historyPos = len(history) - 1 historyPos = len(history) - 1
if len(history) > 0 { if len(history) > 0 {
foundLine = history[historyPos] foundLine = history[historyPos]
@ -761,7 +726,7 @@ mainLoop:
case ctrlP: // up case ctrlP: // up
historyAction = true historyAction = true
if historyStale { if historyStale {
historyPrefix = s.getHistoryByPrefix(string(line), ModeGlobal) historyPrefix = s.getHistoryByPrefix(string(line))
historyPos = len(historyPrefix) historyPos = len(historyPrefix)
historyStale = false historyStale = false
} }
@ -779,7 +744,7 @@ mainLoop:
case ctrlN: // down case ctrlN: // down
historyAction = true historyAction = true
if historyStale { if historyStale {
historyPrefix = s.getHistoryByPrefix(string(line), ModeGlobal) historyPrefix = s.getHistoryByPrefix(string(line))
historyPos = len(historyPrefix) historyPos = len(historyPrefix)
historyStale = false historyStale = false
} }
@ -947,7 +912,7 @@ mainLoop:
case up: case up:
historyAction = true historyAction = true
if historyStale { if historyStale {
historyPrefix = s.getHistoryByPrefix(string(line), ModeGlobal) historyPrefix = s.getHistoryByPrefix(string(line))
historyPos = len(historyPrefix) historyPos = len(historyPrefix)
historyStale = false historyStale = false
} }
@ -964,7 +929,7 @@ mainLoop:
case down: case down:
historyAction = true historyAction = true
if historyStale { if historyStale {
historyPrefix = s.getHistoryByPrefix(string(line), ModeGlobal) historyPrefix = s.getHistoryByPrefix(string(line))
historyPos = len(historyPrefix) historyPos = len(historyPrefix)
historyStale = false historyStale = false
} }

67
main.go
View File

@ -66,11 +66,6 @@ func migrateDatabase(conn *sql.DB, currentVersion int) {
migrations := []string{ migrations := []string{
"ALTER TABLE history ADD COLUMN workdir varchar(4096) DEFAULT ''", "ALTER TABLE history ADD COLUMN workdir varchar(4096) DEFAULT ''",
"ALTER TABLE history ADD COLUMN retval integer DEFAULT -9001", "ALTER TABLE history ADD COLUMN retval integer DEFAULT -9001",
"ALTER TABLE history ADD COLUMN unix_tmp integer",
"UPDATE history SET unix_tmp = strftime('%s', timestamp)",
"DROP VIEW count_by_date",
"ALTER TABLE history DROP COLUMN timestamp",
"ALTER TABLE history RENAME COLUMN unix_tmp TO timestamp",
} }
if !(len(migrations) > currentVersion) { if !(len(migrations) > currentVersion) {
@ -162,14 +157,13 @@ type searchopts struct {
after *time.Time after *time.Time
before *time.Time before *time.Time
retval *int retval *int
order *string order string
limit *int
} }
func search(conn *sql.DB, opts searchopts) list.List { func search(conn *sql.DB, opts searchopts) list.List {
args := make([]interface{}, 0) args := make([]interface{}, 0)
var sb strings.Builder var sb strings.Builder
sb.WriteString("SELECT id, command, workdir, user, hostname, retval, timestamp ") sb.WriteString("SELECT id, command, workdir, user, hostname, retval ")
sb.WriteString("FROM history ") sb.WriteString("FROM history ")
sb.WriteString("WHERE 1=1 ") //1=1 so we can append as many AND foo as we want, or none sb.WriteString("WHERE 1=1 ") //1=1 so we can append as many AND foo as we want, or none
@ -182,11 +176,11 @@ func search(conn *sql.DB, opts searchopts) list.List {
args = append(args, opts.workdir) args = append(args, opts.workdir)
} }
if opts.after != nil { if opts.after != nil {
sb.WriteString("AND timestamp > ? ") sb.WriteString("AND timestamp > datetime(?, 'unixepoch') ")
args = append(args, opts.after.Unix()) args = append(args, opts.after.Unix())
} }
if opts.before != nil { if opts.before != nil {
sb.WriteString("AND timestamp < ? ") sb.WriteString("AND timestamp < datetime(?, 'unixepoch') ")
args = append(args, opts.before.Unix()) args = append(args, opts.before.Unix())
} }
if opts.retval != nil { if opts.retval != nil {
@ -194,18 +188,7 @@ func search(conn *sql.DB, opts searchopts) list.List {
args = append(args, opts.retval) args = append(args, opts.retval)
} }
sb.WriteString("ORDER BY timestamp ") sb.WriteString("ORDER BY timestamp ")
if opts.order != nil {
sb.WriteString(*opts.order)
sb.WriteRune(' ')
} else {
sb.WriteString("ASC ") sb.WriteString("ASC ")
}
if opts.limit != nil {
sb.WriteString("LIMIT ")
sb.WriteString(strconv.Itoa(*opts.limit))
sb.WriteRune(' ')
}
queryStmt := sb.String() queryStmt := sb.String()
@ -218,12 +201,10 @@ func search(conn *sql.DB, opts searchopts) list.List {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var entry HistoryEntry var entry HistoryEntry
var timestamp int64 err = rows.Scan(&entry.id, &entry.cmd, &entry.cwd, &entry.user, &entry.hostname, &entry.retval)
err = rows.Scan(&entry.id, &entry.cmd, &entry.cwd, &entry.user, &entry.hostname, &entry.retval, &timestamp)
if err != nil { if err != nil {
log.Panic(err) log.Panic(err)
} }
entry.timestamp = time.Unix(timestamp, 0)
result.PushBack(&entry) result.PushBack(&entry)
} }
return result return result
@ -239,7 +220,7 @@ func delete(conn *sql.DB, entryId uint32) {
} }
func add(conn *sql.DB, entry HistoryEntry) { func add(conn *sql.DB, entry HistoryEntry) {
stmt, err := conn.Prepare("INSERT INTO history (user, command, hostname, workdir, timestamp, retval) VALUES (?, ?, ?, ?, ?,?)") stmt, err := conn.Prepare("INSERT INTO history (user, command, hostname, workdir, timestamp, retval) VALUES (?, ?, ?, ?, datetime(?, 'unixepoch'),?)")
if err != nil { if err != nil {
log.Panic(err) log.Panic(err)
} }
@ -328,7 +309,6 @@ func main() {
PROMPT_COMMAND='hs9001 add -ret $? "$(history 1)"' PROMPT_COMMAND='hs9001 add -ret $? "$(history 1)"'
bind -x '"\C-r": " READLINE_LINE=$(hs9001 bash-ctrlr 3>&1 1>&2 2>&3) READLINE_POINT=0"' bind -x '"\C-r": " READLINE_LINE=$(hs9001 bash-ctrlr 3>&1 1>&2 2>&3) READLINE_POINT=0"'
fi fi
alias hs='hs9001 search'
`) `)
case "bash-disable": case "bash-disable":
fmt.Printf("unset PROMPT_COMMAND\n") fmt.Printf("unset PROMPT_COMMAND\n")
@ -358,14 +338,13 @@ func main() {
var afterTime string var afterTime string
var beforeTime string var beforeTime string
var distinct bool = true var distinct bool = true
var today bool = false
var retVal int var retVal int
searchCmd.StringVar(&workDir, "cwd", "", "Search only within this workdir") searchCmd.StringVar(&workDir, "cwd", "", "Search only within this workdir")
searchCmd.StringVar(&afterTime, "after", "", "Start searching from this timeframe") searchCmd.StringVar(&afterTime, "after", "", "Start searching from this timeframe")
searchCmd.StringVar(&beforeTime, "before", "", "End searching from this timeframe") searchCmd.StringVar(&beforeTime, "before", "", "End searching from this timeframe")
searchCmd.BoolVar(&distinct, "distinct", true, "Remove consecutive duplicate commands from output") searchCmd.BoolVar(&distinct, "distinct", true, "Remove consecutive duplicate commands from output")
searchCmd.BoolVar(&today, "today", false, "Search only today's entries. Overrides --after")
searchCmd.IntVar(&retVal, "ret", -9001, "Only query commands that returned with this exit code. -9001=all (default)") searchCmd.IntVar(&retVal, "ret", -9001, "Only query commands that returned with this exit code. -9001=all (default)")
searchCmd.Parse(globalargs) searchCmd.Parse(globalargs)
args := searchCmd.Args() args := searchCmd.Args()
@ -373,8 +352,7 @@ func main() {
q := strings.Join(args, " ") q := strings.Join(args, " ")
opts := searchopts{} opts := searchopts{}
o := "ASC" opts.order = "ASC"
opts.order = &o
if q != "" { if q != "" {
cmd := "%" + q + "%" cmd := "%" + q + "%"
opts.command = &cmd opts.command = &cmd
@ -386,11 +364,6 @@ func main() {
} }
opts.workdir = &wd opts.workdir = &wd
} }
if today {
afterTime = "today"
}
if afterTime != "" { if afterTime != "" {
afterTimestamp, err := naturaldate.Parse(afterTime, time.Now()) afterTimestamp, err := naturaldate.Parse(afterTime, time.Now())
if err != nil { if err != nil {
@ -411,35 +384,15 @@ func main() {
results := search(conn, opts) results := search(conn, opts)
previousCmd := "" previousCmd := ""
previousReturn := -1
fi, err := os.Stdout.Stat()
if err != nil {
panic(err)
}
//Don't print colors if output is piped
printColors := true
if (fi.Mode() & os.ModeCharDevice) == 0 {
printColors = false
}
for e := results.Front(); e != nil; e = e.Next() { for e := results.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*HistoryEntry) entry, ok := e.Value.(*HistoryEntry)
if !ok { if !ok {
log.Panic("Failed to retrieve entries") log.Panic("Failed to retrieve entries")
} }
if !distinct || !(previousCmd == entry.cmd && previousReturn == entry.retval) { if !distinct || previousCmd != entry.cmd {
prefix := "" fmt.Printf("%s\n", entry.cmd)
postfix := ""
if printColors && entry.retval != 0 {
prefix = "\033[38;5;88m"
postfix = "\033[0m"
}
fmt.Printf("%s%s%s\n", prefix, entry.cmd, postfix)
} }
previousCmd = entry.cmd previousCmd = entry.cmd
previousReturn = entry.retval
} }
if cmd == "delete" { if cmd == "delete" {