shell history helper
Find a file
Vincent Batts 69bf49ad92 init: add 'gosh init systemd' to print the service and timer units
ExecStart uses the path of the running gosh (%h under the home directory),
-root and -calendar set what is scanned and how often, and an explicit -db
is passed on. 'service' or 'timer' prints one unit for redirecting to a file.
2026-10-17 15:11:49 -04:00
docs timeline: list in time order, -group for blocks; report: one (web) row 2026-10-17 15:02:54 -04:00
examples Add a plugin system for reporting activity from other tools 2026-10-15 13:52:26 -04:00
.gitignore Rewrite as a project activity tracker on SQLite 2026-10-15 10:02:38 -04:00
db.go Migrate older databases to add history pid/ppid columns 2026-10-16 09:38:10 -04:00
docs.go init: add 'gosh init systemd' to print the service and timer units 2026-10-17 15:11:49 -04:00
docs_test.go init: add 'gosh init systemd' to print the service and timer units 2026-10-17 15:11:49 -04:00
go.mod Rewrite as a project activity tracker on SQLite 2026-10-15 10:02:38 -04:00
go.sum Rewrite as a project activity tracker on SQLite 2026-10-15 10:02:38 -04:00
history.go timeline: list in time order, -group for blocks; report: one (web) row 2026-10-17 15:02:54 -04:00
LICENSE Rewrite as a project activity tracker on SQLite 2026-10-15 10:02:38 -04:00
location.go plugins: accept URIs as an activity's path 2026-10-17 08:17:58 -04:00
location_test.go plugins: accept URIs as an activity's path 2026-10-17 08:17:58 -04:00
main.go init: add 'gosh init systemd' to print the service and timer units 2026-10-17 15:11:49 -04:00
plugin.go plugins: protocol 2 for plugins that report URIs 2026-10-17 08:44:48 -04:00
plugin_test.go plugins: protocol 2 for plugins that report URIs 2026-10-17 08:44:48 -04:00
project.go Rewrite as a project activity tracker on SQLite 2026-10-15 10:02:38 -04:00
README.md init: add 'gosh init systemd' to print the service and timer units 2026-10-17 15:11:49 -04:00
report.go report: add -by stream for time per files, cmds and each plugin 2026-10-17 15:10:06 -04:00
scan.go Add a plugin system for reporting activity from other tools 2026-10-15 13:52:26 -04:00
streams.go report, timeline: add -only and -exclude to select event streams 2026-10-17 07:56:49 -04:00
streams_test.go timeline: list in time order, -group for blocks; report: one (web) row 2026-10-17 15:02:54 -04:00
timeline.go timeline: list in time order, -group for blocks; report: one (web) row 2026-10-17 15:02:54 -04:00
tree.go report -by path: show - for rows covering a single event 2026-10-17 08:36:04 -04:00
tree_test.go report: add -by stream for time per files, cmds and each plugin 2026-10-17 15:10:06 -04:00

gosh

Work out what you've been spending your time on, using only what is already on disk.

gosh records file modification times grouped by git project, joins them with your bash history and a log of the directory each command ran in, and keeps it all in a small SQLite database. From that it can report time spent per project and replay a day's work as a single timeline of commands and file changes.

It is the successor to persistent-shell-history (ruby + berkeley dbm).

How it works

  • Projects. A project is the nearest ancestor directory containing .git. When scanning, a repository above the scan root (say a dotfiles repo in $HOME) does not count; the scan root itself stands in as the project.
  • Files. Each scan upserts every regular file's mtime and size. Only the latest mtime per file is kept. Files that disappear are marked deleted_at rather than removed.
  • Commands. Bash history is imported from the #<epoch> lines that bash writes when HISTTIMEFORMAT is set. Bash history does not record where a command ran, so an optional prompt hook logs epoch pid ppid cwd command (tab separated): the directory the command started in, and the pid and parent pid of the bash that ran it, which ties commands to a project and to a terminal.
  • Sessions. Events (file mtimes and command times) for a project are grouped into sessions: a gap of more than 30 minutes ends one. Each session is credited 5 minutes before its first event, since work starts before the first write.

Skipped directories during a scan: .git, node_modules, .cache, target, __pycache__, .venv, vendor.

Install

Requires Go. No cgo (SQLite is modernc.org/sqlite).

go install git.batts.cloud/vbatts/gosh@latest

or from a checkout:

go build -o gosh .

Setup

  1. Make sure bash writes timestamps to its history file, in ~/.bashrc:

    export HISTTIMEFORMAT="%F %T "
    
  2. Optionally enable the cwd hook, also in ~/.bashrc (after anything that sets or unsets PROMPT_COMMAND):

    eval "$(gosh init bash)"
    

    This appends to ~/.local/state/gosh/cwd.log ($XDG_STATE_HOME and $GOSH_CWD_LOG are honoured). The file is created mode 0600, since commands may contain secrets.

  3. Collect data:

    gosh import
    gosh scan -root ~/src
    gosh collect        # only if you have plugins, see below
    

    All three are idempotent, so they are safe to run as often as you like.

  4. Run them on a schedule, see the next section. The shell hook only records commands; nothing is loaded into the database until import, scan and collect run.

Running on a schedule

eval "$(gosh init bash)" does not run gosh. It appends each command to cwd.log and that's all. Loading it, scanning files and running plugins is up to you, and a systemd user timer is the easiest way to do it.

How often matters, and differently for each source:

  • Files: gosh scan records only a file's latest mtime, so the more often it runs the finer the history. A scan of about 30,000 files takes under two seconds.
  • Commands: the hook writes cwd.log as you work, so a late import loses nothing. Bash itself writes .bash_history only when a shell exits.
  • Plugins: collect is the only thing that reads them. A late run loses nothing while the source still has its data, but a tool may expire its old logs, so don't leave long gaps.

gosh init systemd prints both units below, filled in with the path of the gosh you ran, so you needn't edit them:

mkdir -p ~/.config/systemd/user
gosh init systemd service > ~/.config/systemd/user/gosh.service
gosh init systemd timer   > ~/.config/systemd/user/gosh.timer

-root dir (repeatable) sets what scan covers and -calendar spec how often it runs (default *:0/15). A -db given before init is passed on to every command. By hand, they are ~/.config/systemd/user/gosh.service:

[Unit]
Description=Collect gosh activity

[Service]
Type=oneshot
# the leading "-" keeps one failure from skipping the rest
ExecStart=-%h/go/bin/gosh import
ExecStart=-%h/go/bin/gosh scan
ExecStart=-%h/go/bin/gosh collect

~/.config/systemd/user/gosh.timer:

[Unit]
Description=Collect gosh activity regularly

[Timer]
OnCalendar=*:0/15
Persistent=true

[Install]
WantedBy=timers.target

Then:

systemctl --user daemon-reload
systemctl --user enable --now gosh.timer
systemctl --user list-timers gosh.timer     # when it next runs
journalctl --user -u gosh.service           # what the last run did

Notes:

  • The ExecStart paths are where gosh was when you ran init systemd; if you move it, regenerate the service. By hand, go install puts it in ~/go/bin. After upgrading, reinstall it, or the timer keeps running the old binary.
  • scan defaults to ~/src. To scan elsewhere, give it -root (repeatable): ExecStart=-%h/go/bin/gosh scan -root %h/src -root %h/work.
  • Systemd's PATH is minimal, but plugins still work: gosh plugin add records each plugin's absolute path. A plugin that itself needs other programs (gosh-git needs git) finds them in the usual system directories.
  • OnCalendar=*:0/15 means every 15 minutes. Persistent=true runs a missed run when the machine wakes. Use OnCalendar=hourly for a lighter schedule.
  • Without systemd, the same three commands work from cron: */15 * * * * ~/go/bin/gosh import; ~/go/bin/gosh scan; ~/go/bin/gosh collect

Usage

gosh [-db path] <command> [flags]

The database defaults to ~/.config/gosh/state.db.

Command Description
scan [-root dir]... Record file mtimes under each root (default ~/src).
import [-hist file] [-cwdlog file] Load shell history and the cwd log.
report [-since 7d] [-by project|day|path|stream] [-prefix loc] [-depth n] [-sort active|last] [-gap 30m] [-min 5m] [-only streams] [-exclude streams] Active time per project, day, path or event stream.
plugin add <name|path> / check <name|path> / list / remove <name> Manage plugins; check validates one without storing anything (see below).
collect [-plugin name] [-full] Run the registered plugins and store the activity they report.
timeline [-since 1d] [-project text] [-group] [-prefix loc] [-depth n] [-gap 30m] [-only streams] [-exclude streams] Commands, file changes and plugin activity in time order; -group gathers them per project.
init bash / init systemd [service|timer] [-calendar spec] [-root dir]... Print the bash prompt hook, or the systemd user units for running import, scan and collect.
help [setup|schedule|usage|plugins|readme] Setup and systemd timer instructions, or this README and the plugin guide in full. They are compiled into the binary, so they come with go install.

-since accepts d and w suffixes as well as anything time.ParseDuration does.

report and timeline take -only and -exclude, comma separated lists of event streams: files, cmds, plugins (every plugin) or a registered plugin's name. -exclude wins, so -only plugins -exclude git is every plugin but git. Unknown names are an error.

$ gosh report -exclude files        # time without file mtimes
$ gosh report -by stream            # time per source: files, cmds, each plugin
$ gosh timeline -only claude        # just that plugin's activity

Report

$ gosh report -since 14d
PROJECT                          ACTIVE  SESSIONS  FILES  CMDS  LAST
~/src/Aspen/Willet/infra-config  1h33m   4         103    12    2026-10-14 14:12
~/src/gosh                       0h06m   1         8      3     2026-10-15 09:51

By stream

-by stream has one row per event source: files, cmds and each plugin. A stream's time comes from its own events alone, so the rows overlap and do not add up to the other views' totals. Use it to see which plugin the time is coming from.

$ gosh report -by stream
STREAM   ACTIVE  SESSIONS  EVENTS  LAST
firefox  24h17m  55        890     2026-10-17 14:05
claude   7h12m   14        984     2026-10-17 14:10
cmds     6h17m   15        191     2026-10-15 10:20

By path

-by path ignores git projects and groups by location instead, like du. Files count at their own path, commands at the directory they ran in, and plugin events at their path or URI. -prefix picks where to start (a path, ~/..., or a URI) and -depth how many levels below it to list; drill down by moving the prefix.

$ gosh report -by path -prefix ~/src -depth 2
$ gosh report -by path -prefix ~/src/gosh        # down to individual files
$ gosh report -by path -prefix https://github.com -depth 2 -only firefox

A row's time comes from everything at or below it taken together, so a parent is not the sum of its children (they overlap in time). With no -prefix, commands that have no known directory are listed as (no cwd). A row covering a single event, such as a file with one known mtime, shows - for ACTIVE: one timestamp has no duration. Real per-file time needs a plugin that reports it repeatedly.

Timeline

$ gosh timeline -since 2d -project gosh -group

== ~/src/gosh  2026-10-15 09:50 - 09:51 (0h01m)
09:50:28  ~ ~/src/gosh/db.go
09:50:51  ~ ~/src/gosh/report.go
09:51:36  ~ ~/src/gosh/main.go
09:51:47  $ cd ~/src/gosh    (~/src/gosh)
09:51:47  $ ls >/dev/null    (~/src/gosh)

Without -group the timeline is one flat list in time order, with the day as a heading and a blank line wherever the gap is exceeded; each line is tagged [project] (or [site] for a URI) instead. -group gives the blocks above, one per session for each project.

~ is a file modification and $ is a command, shown with its working directory. A command's directory and the shell's pid are shown when known: $ cmd (cwd) [pid]. Commands with no project get their own (no cwd) and (no repo) blocks.

-prefix shows only events at or under a path or URI, placed by location instead of by git project, and -depth (which needs -group) splits the blocks by that many levels below it. -depth 0 (the default) is the prefix as a whole, which is handy for a file-level view of one directory or site:

$ gosh timeline -prefix ~/src/gosh/docs
$ gosh timeline -group -prefix https://github.com -depth 2

Commands with no known directory have no location, so a -prefix leaves them out. -project is unchanged and still matches a substring of the project root.

Plugins

Other tools can report activity too, such as an AI coding agent's sessions. A plugin is an executable named gosh-<name>, found on your PATH the way git finds subcommands. Plugins never touch the database; gosh runs them and stores what they print.

gosh plugin add claude      # runs `gosh-claude describe`, records name, version, path
gosh collect                # runs every registered plugin

The protocol (version 2; version 1, absolute paths only, is still accepted):

gosh-<name> describe
    {"name":"claude","version":"0.1.0","protocol":2}

gosh-<name> collect --since <unix seconds>
    one JSON object per line:
    {"ts":1792072307,"path":"/abs/dir","kind":"prompt","ref":"<unique id>","session":"<id>","detail":"..."}

ts, path (an absolute path or a URI), kind and ref are required; ref must be unique per event within the plugin, which makes re-reporting an event harmless. gosh maps path to a project exactly as it does for shell commands, and asks for activity from an hour before the newest event it has stored (collect -full asks for everything). Each plugin's events are stored all or nothing: a plugin that exits non-zero stores none from that run.

Plugin activity counts toward report (the PLUGINS column and active time) and appears in timeline as @<plugin> <kind> lines. Events in a directory outside any git repo are shown under (no repo). In report a URI's activity is a single (web) row; report -by path breaks it down by site and page.

To write your own, see docs/PLUGINS.md, which has a quickstart, the full protocol, design advice, and gosh plugin check for validating a plugin. A working example is in examples/gosh-git.

Plugins so far:

Limitations

  • Only a file's most recent modification is known, so the timeline shows each file once, at its last write. Scanning more often narrows the picture. Commands are complete.
  • Commands imported from .bash_history alone have no directory, and nothing in that file can recover one. They are never guessed into a project: they appear under (no cwd). Only commands logged by the prompt hook are tied to a project, so install the hook early. (no repo) is for commands whose directory is known but not inside a git repo.
  • Bash writes a session's history when the shell exits. The prompt hook writes at every prompt, so it covers long-running terminals.
  • With HISTCONTROL=ignoreboth, commands starting with a space and immediate repeats never reach history or the hook.
  • A command that changes shell state mid-line (cd a && make) is logged with the directory it started in.
  • cwd.log is re-read in full on every import.

License

MIT, see LICENSE.