Skip to content

fix: implement history -n and history -r#1136

Draft
melderan wants to merge 2 commits into
reubeno:mainfrom
melderan:jmo-history-flags
Draft

fix: implement history -n and history -r#1136
melderan wants to merge 2 commits into
reubeno:mainfrom
melderan:jmo-history-flags

Conversation

@melderan

@melderan melderan commented May 4, 2026

Copy link
Copy Markdown

Summary

Implements history -n and history -r, which previously returned error: not yet implemented. Also retags the history -p stub via unimp_with_issue(..., 100) so anyone hitting it lands on #100 (history expansion) directly.

The motivating case: a __history_sync function in PROMPT_COMMAND (a common pattern for keeping multi-terminal history coherent) calls history -a (works) and history -n (errors today). With this fix, those configurations stop spamming stderr on every prompt cycle.

Closes #1134.

Behavior — matches bash

  • history -n [filename]: reads all entries from the file that haven't already been read into this session, appending them to the in-memory history. Tracks a per-session "last read offset" via a new last_file_read_count field on History.
  • history -r [filename]: appends the full file contents to the in-memory history. Note: bash's man page says "use them as the current history," but the actual bash implementation appends rather than replaces — verified empirically with bash --norc --noprofile -c 'history -s in-mem; history -r src.txt; history'. The tests follow the empirical behavior.
  • Both flags accept an optional explicit file argument; absent that, fall back to $HISTFILE.
  • Truncation recovery: if the history file shrinks externally between two -n calls (log rotation, another session doing history -w), last_file_read_count is reset to the new (smaller) count so subsequent appends remain visible.
  • 1 GiB file-size guard is applied to -n and -r, matching the existing load_history behavior. While we were there, the constant was lifted to a single pub(crate) const so the threshold stays in lockstep across the three import paths.

Design notes worth flagging

  • last_file_read_count is serde(skip): it's session-local state. Resetting to 0 across serialization boundaries is safe — a subsequent -n just re-reads everything from the file (the conservative outcome).
  • clear() resets last_file_read_count to 0 (so history -c; history -n reads everything from the file, matching bash). next_id is intentionally not reset — comment in code explains why (reedline ID stability across clear/reload cycles).
  • Items loaded from the file are constructed via direct Item { dirty: false, ... } literal, not Item::new() (which sets dirty: true). That ensures history -a doesn't double-write entries that came from the file.
  • Reedline-clone investigation: brush-interactive/src/reedline/history.rs accesses History exclusively through MutexGuard borrows; no History::clone() calls. The Clone derive on the struct exists but is not exercised by the reedline integration, so last_file_read_count doesn't get reset mid-session.

Tests

  • 8 new compat test cases under brush-shell/tests/cases/compat/builtins/history.yaml covering: load file, append-vs-replace, explicit file argument, incremental -n reads, idempotence, post-history -c behavior, and truncation recovery. All use args: ["-o", "history"] for deterministic non-interactive sequencing.
  • 2 new unit tests (test_parse_dash_n, test_parse_dash_r) mirroring the existing test_parse_dash_a.

The new tests use skip_oracle: true + expected_stdout rather than bash-oracle comparison. Reason: brush loads $HISTFILE at startup in non-interactive mode while bash doesn't, which causes oracle divergence on incidental output unrelated to the feature being tested. This is a pre-existing brush↔bash divergence, not introduced by this PR. Happy to switch to oracle comparison if the maintainer prefers; this seemed like the lower-risk choice.

Known related gap (not in this PR)

When HISTFILE is unset and no explicit file argument is given, -a/-w/-n/-r silently no-op (exit 0). Bash emits a diagnostic to stderr in the same situation (also exit 0). Filed as #1135. Doesn't affect the motivating __history_sync case (where HISTFILE is set), but worth tracking.

Local verification

  • unset BASH_ENV && cargo xtask ci pre-commit — fmt, build, lint, deps, schemas, unit tests all green; integration tests pass except for the pre-existing brush-interactive-tests::run_in_bg_then_fg flake unrelated to this change (also fails on main on this dev machine; the local .bashrc references mise/direnv/atuin/zoxide hook functions that aren't present in CI).
  • The original repro brush -i <<< 'history -n; exit' now exits clean instead of printing an error.

Two commits, one story

  1. fix: implement history -n and history -r — the actual implementation
  2. refactor: lift MAX_FILE_SIZE_FOR_HISTORY_IMPORT to module scope — small follow-on dedupe of the file-size constant across the three import paths

Happy to squash if the maintainer prefers a single commit.

Filed as a draft for early input.


Assisted-by: Claude (Anthropic) (JMO's friend)

melderan added 2 commits May 4, 2026 18:49
`history -n` and `history -r` previously returned "not yet implemented"
errors. JMO's shell config calls `history -n` in `__history_sync` on
every prompt cycle, causing a stderr error on every prompt.

Changes:

brush-core/src/history.rs:
- Add `last_file_read_count: usize` field to `History` (serde-skipped so
  it resets to 0 across any serialization boundary -- safe, as a
  subsequent -n will just re-read all new entries from that baseline).
  `import` sets it to `self.count()` after loading so that a fresh
  session's -n call correctly skips already-loaded entries.
- Extract `parse_and_append_lines` from `import`. Items are always
  constructed via direct `Item { dirty: false, ... }` struct literal
  (never `Item::new()`, which sets dirty: true) because file-loaded
  entries are already persisted.
- Add `read_new_from_file` (history -n): re-parses the full file into a
  scratch History, then appends only the entries beyond
  `last_file_read_count`. Truncation recovery: if
  `scratch.count() < last_file_read_count`, the file was truncated
  externally -- reset the baseline to `scratch.count()` without
  appending so that future appends to the file remain visible.
  Refuses files > 1 GiB (matches `load_history` guard).
- Add `reload_from_file` (history -r): appends the full file content to
  the current in-memory history (bash -r semantics: append, not replace).
  Sets `last_file_read_count = scratch.count()` so subsequent -n calls
  know where to start.
- Update `clear` to also reset `last_file_read_count = 0`. NOTE: `next_id`
  is intentionally not reset -- item IDs must remain monotonically
  increasing across a clear/reload cycle for reedline stability.

brush-builtins/src/history.rs:
- Wire `-n` to `history.read_new_from_file(file_path)?` using the
  existing `get_effective_history_file_path` helper (supports optional
  explicit path, falls back to HISTFILE).
- Wire `-r` to `history.reload_from_file(file_path)?` with same pattern.
- Upgrade `-p` stub from `error::unimp(...)` to
  `error::unimp_with_issue("history -p is not yet implemented", 100)`.
  Issue reubeno#100 confirmed: "Implement history expansion (word designators,
  event designators)" in reubeno/brush -- exactly the right tracker.

Reedline clone investigation (plan step 4): `brush-interactive/src/reedline/history.rs`
accesses `History` only through `MutexGuard` borrows (`get_shell_history` /
`get_shell_history_mut`). No `History::clone()` calls observed. The `Clone`
derive exists on the struct but is not exercised by the reedline integration.
`last_file_read_count` is therefore not reset mid-session by the reedline
layer.

Tests:
- 8 new compat test cases in brush-shell/tests/cases/compat/builtins/history.yaml.
  All use `skip_oracle: true` + `expected_stdout`: bash non-interactive mode does
  not load HISTFILE at startup (brush does), and bash records fewer stdin commands
  in history in the test harness. Oracle comparison would fail on incidental
  differences unrelated to the feature being tested.
- 2 new unit tests in brush-builtins/src/history.rs:
  `test_parse_dash_n` and `test_parse_dash_r`, mirroring `test_parse_dash_a`.

Pre-commit gate: cargo xtask ci pre-commit passes. The sole failure
(run_in_bg_then_fg) is the pre-existing flake that also fails on main.

Assisted-by: Claude (Anthropic) (JMO's friend)
The 1 GiB history-file size guard was duplicated as a function-local
const in three places: `load_history`, `read_new_from_file`, and
`reload_from_file`. Lift to a single `pub(crate) const` at the
top of `brush-core/src/history.rs` so the guard stays in lockstep
across all import paths if the threshold is ever revisited.

No behavior change.

Assisted-by: Claude (Anthropic) (JMO's friend)

@reubeno reubeno left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for tackling these options!

I've left a few initial questions / comments.


# Tests for history -r and history -n.
#
# These tests use skip_oracle + expected_stdout rather than comparing against bash's oracle,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I'm not sure I follow why the oracle testing approach can't be used? The other tests in this file are able to compare history loading/manipulation against bash's behavior.

Are you suggesting that there's something unique about the options targeted by this PR?

Comment thread brush-core/src/history.rs
/// Serde-skipped: not persisted across serialization boundaries; resets to 0 on
/// deserialization (safe — subsequent `-n` reads everything new from the file).
#[cfg_attr(feature = "serde", serde(skip))]
last_file_read_count: usize,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Is this captured independently of which file was last read? Do we have any new test cases that check behavior when subsequent invocations use different history file paths (or the history file has since been changed underneath us)?

Comment thread brush-core/src/history.rs
next_id: ItemId,
/// Tracks how many items were present in the history file the last time we read it.
/// Used by `read_new_from_file` (`history -n`) to skip already-loaded entries.
/// Serde-skipped: not persisted across serialization boundaries; resets to 0 on

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

What's the thinking of not persisting it? Is your concern that the history file has been changed in the intervening time since serialization?

Comment thread brush-core/src/history.rs
/// # Arguments
///
/// * `path` - The path to the history file.
pub fn read_new_from_file(&mut self, path: impl AsRef<Path>) -> Result<(), error::Error> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This and the function below are quite similar; any way to share logic to ensure they don't drift from each other?

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.

bug: history -n and history -r not implemented; emits stderr error per prompt cycle

2 participants