Visitar URL original
Add history JSONL file format proposal by ridiculousfish · Pull Request #12982 · fish-shell/fish-shell · GitHub
Skip to content

Add history JSONL file format proposal - #12982

Open
ridiculousfish wants to merge 1 commit into
fish-shell:masterfrom
ridiculousfish:jsonl-history-proposal
Open

ridiculousfish wants to merge 1 commit into
fish-shell:masterfrom
ridiculousfish:jsonl-history-proposal

Conversation

@ridiculousfish

@ridiculousfish ridiculousfish commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

This is yet another run at replacing the psuedo-YAML history file format. This has been discussed a lot - most recently in #10680.

This PR matures the ideas and fills in the gaps. It adds internals documentation for the proposed format. If and when this is merged, we'll have follow-up PRs that implement the design we settle on.

Relative to #10680, the big changes here are:

  1. Use JSONLines instead of JSON Text Sequences. JSONLines is simpler, has no hidden control characters, and works better with other Unix tools.
  2. Introduce a history item ID. Each item can have its data spread across multiple records, such as one record for the command text, and a second for its exit status and duration (which are known later). These records are linked together because they have the same item ID.
  3. No more deduplication. Each command is a separate item.
  4. More item metadata (exit code, duration, session id, PWD...) that will later be exposed via the history builtin.

I've been living on a proof of concept implementation for a while now, and it really does result in a simplification, while adding more features and better performance relative to the current format. I'm convinced this direction will be a huge improvement.

See the added file for the format details - in particular the idea of history item IDs, vacuuming, and migration plan. Here's a link to the file rendered as markdown.

This briefly documents the existing history file format, and describes
the plan for an upcoming format intended to replace it.

### File parsing

fish at startup quickly scans the history file and records only the offsets of the record delimeters. These offsets form an index into the history file, allowing fish to locate individual records quickly, and parse them lazily. This avoids a "big up-front parse" at launch time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: delimiters

if we play some tricks to avoid depending on the number of history entries, we could probably avoid parsing offsets eagerly too. But that's unrelated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that's a very valid possible future optimization

Comment thread doc_internal/fish-jsonl-history-plan.md Resolved
Comment thread doc_internal/fish-jsonl-history-plan.md Resolved

This design is similar to a [ULID](https://github.com/ulid/spec).

This timestamp gives millisecond precision over nearly 9000 years. The nonce is randomized per millisecond, and incremented within a millisecond. So ids within a single session are monotone increasing, and collisions between sessions are very unlikely: the user would need to run dozens of commands in different sessions all in the same millisecond.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess history append in concurrent sessions could trigger a collision.
Dropping some parts of those history items (at vacuum time) seems okay.
If we use the session ID in addition to the item ID, then collisions are even less likely.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point. I think you've hit on the right idea and we should take it a step farther by folding the session ID into the item ID. This pushes it up to 22 chars but makes collisions between sessions impossible unless the sessions themselves collide:

A fish history item is identified by its "id:" a 128 bit unsigned integer. This contains the item's 48 bit timestamp, a 16 bit nonce, and the 64 bit session id.
The session ID is itself composed of a 48 bit session timestamp and a 16 bit session nonce.

  Bits 127            80 79       64 63                  16 15         0
  +---------------------+-----------+----------------------+------------+
  | timestamp (48)      | nonce (16)| session timestamp (48)| nonce (16)|
  +---------------------+-----------+----------------------+------------+

The net effect is tiny since we remove the session ID as a separate field. It slightly complicates decoding but not in a significant way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why we'd fold timestamp, session ID and nonce into a single opaque JSON field?
We could also make it

{"started": <timestamp>, "session": <timestamp>, "nonce": <nonce>, ...}

and use all three as composite primary key.

This gives more information to readers and enables some jq workflows though I don't have a concrete one.


Occasionally fish will _vacuum_ (rewrite) the file to reduce its size. Vacuuming is triggered by:

1. The file growing past some item count limit (working guess 1 million)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's hard to estimate how deduplication affects item counts in practice.
I feel like there are some commands I re-run very often.
It wouldn't be great if this change would in practice cause history from a few years ago to roll off, for people who run hundreds(?) of commands a day.
Possibly we should deduplicate only items older than a year or so. Or maybe compress them and/or move them to different files.

@krobelus krobelus Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at what others do, I'm surprised to read that Chrome rolls off history after 90 days.

zsh has HIST_EXPIRE_DUPS_FIRST, to roll off duplicate entries before any unique entry. I think that sounds promising, unless we need an accurate history for something.
IIRC, the motivation for non-deduplicated history is purely simplicity of implementation. For example, I imagine that up-arrow history search will still present a deduplicated view to the user. So that should be fine


1. Collapses multiple records for the same ID into a single record
2. Reorders file records by id, putting the file into chronological order
3. Removes oldest items to enforce the history size limit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may want two limits (high/low watermarks) so we don't vacuum at every command once we hit the limit

When fish launches with the new history format, if there is no file at the new path, it will populate it from the previous (extensionless) path. It then prints a message, perhaps:

> The fish history file has been migrated to `~/.local/share/fish/fish_history.jsonl`
> It is safe to delete the previous file at `~/.local/share/fish/fish_history`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so if people have multiple history files, they can do for h in foo bar; fish_history=$h fish; end to migrate them all. This only works when stdin is a TTY, or possibly also for fish_history=$h fish -c 'history search ...'


Occasionally fish will _vacuum_ (rewrite) the file to reduce its size. Vacuuming is triggered by:

1. The file growing past some item count limit (working guess 1 million)

@faho faho Sep 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 million sounds okay. Given the example

{"id":"AZ9TBVz27qQ","cmd":"cat file.txt","paths":["file.txt"],"cwd":"~","exit":0,"dur":4
,"sid":"AAA44pHL-_o"}

is 109 bytes, that comes out to 109 MB for a million entries. Tho the example command is a bit short. My current history has ~41k entries (in a fish_history file of 3.5M), with an average length of ~46 codepoints for the command (which I'm gonna say is like 99% ascii so bytes), and the cwd is minimal here.

So this might end up as ~160MB in practice, which shouldn't be an issue for a fully-loaded complete history of everything. Might not fit on floppy disks, but I hear you can get those new-fangled zip drives with that much memory.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course my current history as-is would be 4.1MB, but had I run those same commands with the new format they'd all be stored, so I wouldn't be at 41k, I'd be at.... gluteal extraction 100k (for 10M).

- `paths` - the list of arguments that resolved to paths, used for autosuggestion hinting
- `sid` - A session identifier unique to each fish instance, used to attach commands to each session

Additional metadata keys may be introduced in future versions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a point of comparison, here's what Atuin stores: https://github.com/atuinsh/atuin/blob/372b092bde6e36cab4fab0bc8696a18e1e4a5d57/crates/atuin/src/command/client/history.rs#L667

It's pretty much the same fields.

They have an explicit timestamp meanwhile in Fish, it would have to be extracted from the ULID-like id prefix.
The host / hostname may be useful when an history is shared between multiple machines.
The user / author may be useful to share history between team members.
Not sure about the purpose of intent.

struct TailJsonHistory<'a> {
    id: HistoryId,
    timestamp: String,
    timestamp_unix_ns: u64,
    command: &'a str,
    cwd: &'a str,
    session: &'a str,
    hostname: &'a str,
    host: &'a str,
    user: &'a str,
    author: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    intent: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    exit: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    duration_ns: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    duration: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    success: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    finished_at: Option<String>,
}

@krobelus

krobelus commented Oct 7, 2026 via email

Copy link
Copy Markdown
Contributor

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants