---
title: "Key history"
description: "Read how one key's translation changed from commit to commit, with the author, where the value came from and a word-level difference."
type: "reference"
---
# Key history

Read how one key's translation changed from commit to commit, with the author, where the value came from and a word-level difference.

<AvailableFrom version="0.13.0" />

## Git history of one key [#git-history-of-one-key]

### keyHistory [#keyhistory]

Lists the commits in which one key's value changed in one target locale, newest first, each with a word-level difference from the previous value. Read-only; it takes a locale and a key, never a path, and runs `git log` and `git cat-file` through the injectable `execFile`.

```ts title="Signature"
function keyHistory(
  input: KeyHistoryInput,
  deps?: KeyHistoryDeps,
): Promise<KeyHistoryResult>;
```

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `config` | `VerbatraConfig` | yes | The resolved project config, normally from loadConfig. |
| `cwd` | `string` | no | Directory the `files.pattern` is resolved against and git runs in. Defaults to the project root of the config object loadConfig returned, else the process working directory; pass `cwd` from resolveProjectRoot for a copied config. |
| `locale` | `string` | yes | A configured target locale. |
| `key` | `string` | yes | The key, at most KEY_HISTORY_MAX_KEY_CHARS characters. It is compared with parsed keys and never passed to git. |
| `limit` | `number` | no | The most commits to examine. Rounded down and clamped to between 1 and LOCALE_HISTORY_LIMIT_CAP; defaults to LOCALE_HISTORY_LIMIT_DEFAULT. |

**Returns:** `KeyHistoryResult`

**Throws:** [`UNKNOWN_LOCALE`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#unknown_locale), [`UNKNOWN_KEY`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#unknown_key), [`LOCALE_LAYOUT_INVALID`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#locale_layout_invalid), [`LOCALE_PATH_COLLISION`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#locale_path_collision)

Each revision carries `commit`, `authorDate`, `author` (the name after `.mailmap`, never the email), `subject`, `value`, `origin`, `sourceChanged` and `diff`. Control and bidirectional formatting characters are stripped from `author` and `subject`, recognised keys in them are replaced by `[REDACTED]` (a former custom key in an unrecognised shape can remain) before they are cut to 200 and 500 characters. A text that one revision gives away as a key (an `api_key=` before a bare UUID, say) is also replaced wherever else it appears in the answer, and `valueRedacted` is set on every value that changed this way. `diff` lists `{ kind, text }` segments with `kind` `equal`, `added` or `removed`: placeholders and tags stay whole, a change of whitespace shows as a change, and a difference too large to align becomes one removed and one added run. `origin` comes from the provenance file as committed with the revision: a stored origin, `unrecorded`, or `external` when its record describes another value, and absent when that file could not be read or parsed, is above 4 MiB, or the time ran out first. `sourceChanged` compares the content hash of the key's current source entry (its text, description, meaning, plural flag and placeholders) with the hash the committed lock-file recorded for the key in that locale: `true` when they differ, `false` when they match, and `"unknown"` when the lock-file, its entry for the key or the current source entry is missing, could not be read or parsed, is above its limit, or the time ran out first. `origin` is separate: it compares the revision's value with the provenance record, and is absent, not `"unknown"`, when that file cannot be used. Restoring a value whose source entry changed makes it count as up to date.

Every value and both sides of every `diff` pass `redact`. A value that held an API key comes back with `[REDACTED]` in its place and `valueRedacted: true`: it is not the stored text, so do not offer it for restore. A key that is no longer configured cannot be recognised and is returned as written.

The commits come from `git log --first-parent` on the locale file (and the `.stringsdict` file for `apple-strings`), so a change a merge brought in is listed on the merge commit. Each file is read with `git cat-file blob` and parsed by the built-in adapter, and a commit is listed only if the key's value differs from the commit right before it in the file's history, which must have been read. A commit that removes the key is not listed. Up to `LOCALE_HISTORY_LIMIT_CAP` commits are examined (`LOCALE_HISTORY_LIMIT_DEFAULT` by default), four files are read at a time, and after `KEY_HISTORY_DEADLINE_MS` (`30000` milliseconds) no further commit is examined and no commit file read starts (the file on disk is still read); the `git log` call, a size listing and a read already running keep their own 10 second limit. `truncated` is `true` when anything the call examined was skipped, unreadable, above a size limit, past a budget or stopped by the deadline, and `truncatedBy` says why, listing every cause that applies from `KEY_HISTORY_TRUNCATION_REASONS`: `commit-limit` (more commits exist than `limit` examines), `shallow-clone`, `unreadable-revision`, `file-too-large`, `byte-budget`, `deadline`, `source-unreadable`, `records-unreadable` (a lock or provenance file) and `scrub-cap` (more than 256 distinct keys, one key longer than 4096 characters, or keys adding up to more than 65,536 UTF-16 code units turned up; the pass cannot cover every text, so every non-empty value, diff segment, subject and author name becomes `[REDACTED]`, empty text stays empty, and `valueRedacted` is set on every revision). `truncatedBy` is empty exactly when `truncated` is `false`, and the file on disk is reported only by `workingTreeUnreadable`. A commit is listed only if the commit before it was read, so a commit whose older neighbour could not be read is not claimed as the key's change.

Sizes come from git before anything is parsed, so the same repository gets the same byte-limit and budget decisions on every machine; a git call that times out, the 30 second deadline and the provider keys in the environment (which decide what is scrubbed) can still change an answer. A locale file above 512 KiB for `yaml` and the XML formats (`xliff`, `android-xml`, `resx`, `apple-strings`) or above 4 MiB for the other formats is skipped unparsed, and the two files of an `apple-strings` commit count as their sum. One call reads at most 16 MiB of locale files for `yaml` and the XML formats and 128 MiB for the others: older commits past that are not examined. A lock file or provenance file above 4 MiB is not read, and one call reads at most 128 MiB of them together: past that, older revisions have no `origin` and `sourceChanged` is `"unknown"`. The source file is read up to the same size as the locale files. Parsing yields to the event loop between files. A commit skipped for its size, that cannot be read or parsed, whose log line did not parse, or whose value is longer than `KEY_HISTORY_MAX_VALUE_CHARS` (`4096`) characters, counts in `unreadableRevisions` and is never returned in part; a commit past a budget is not examined and does not count. `key` is at most `KEY_HISTORY_MAX_KEY_CHARS` (`1024`) characters and never reaches git. `workingTree` carries the value on disk, with its `diff`, when it differs from `HEAD`. When the value on disk cannot be compared with `HEAD` (the file does not parse, as during a merge conflict, is a symbolic link or is above its limit, its value is too long, the copy at `HEAD` could not be read, or time ran out) the revisions are still listed and `workingTreeUnreadable` is `true`.

Renames are not followed, so history before a rename is not listed; edits that were never committed are not seen, and a key renamed in the source looks new. The git safeguards are those of `localeGitStatus`, and the result is `{ available: false, reason }` with a reason from `LOCALE_GIT_UNAVAILABLE_REASONS`, `custom-adapter` for a `custom:` format included.

## Run history [#run-history]

### runHistory [#runhistory]

Reads `.verbatra-local/run-history.jsonl`, where every `translate`, `watch`, `retranslateEntry` and `retranslateEntries` run that left a trace adds one record. It writes nothing, calls no provider and throws nothing.

```ts title="Signature"
function runHistory(
  input?: RunHistoryInput,
  deps?: RunHistoryDeps,
): Promise<RunHistoryResult>;
```

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `cwd` | `string` | no | Directory holding the `.verbatra-local` directory. Defaults to the process working directory, even when the config was found in a parent directory: pass the root resolveProjectRoot returns. |
| `limit` | `number` | no | How many of the newest records to return. A value below 1 returns one record, a value above 200 returns 200, and a value that is not a number returns the default of 50. |

**Returns:** `RunHistoryResult`

`records` come newest first, at most `limit` of them (`RUN_HISTORY_DEFAULT_LIMIT` is 50, `RUN_HISTORY_MAX_LIMIT` is 200; any other value is clamped). Each record holds the time, the `trigger` (`translate` or `retranslate`), the provider and model, token `usage`, the `cost`, and per locale the counts of translated keys, cache hits, fuzzy hits, generated plural forms, pruned keys, integrity refusals and provider failures. A record never holds a key name or a text. A dry run is not recorded, and neither is a run that left no trace, such as a `watch` cycle with nothing to do; a failed or refused run is recorded. `entries` counts the entries a retranslate record attempted, a failed provider call included, and a batch of retranslations is one record. The file keeps the newest 1,000.

`cost` is frozen when the run ends, from the `rates` the config held then. `pricing` `priced` adds `currency`, `asOf` and `amount`; `no-rate-on-file`, `rate-unit-mismatch` and `no-usage` (keys were sent and the provider reported no tokens) carry no amount, which means unknown, not free; `not-billed` (verbatra does not price this provider: a self-hosted `openai-compatible` or LibreTranslate server, or `none`; a hosted endpoint may still bill you) carries neither an amount nor a `basis`, and carries `USAGE_PARTIAL` when part of its usage went unreported. `basis` (one of `RUN_COST_BASES`) `usage` is the provider's token count. `source-characters` is an estimate for DeepL and Google Cloud Translation, which report no usage: the source characters of the keys sent, cache hits left out, refused keys counted, failed keys not, with `caveats` naming what it leaves out. A `usage` figure carrying the caveat `USAGE_PARTIAL` is a lower bound, priced or not: a locale sent keys and reported no tokens, so the amount and the token count cover only what was reported. A locale that reported for some of its sub-batches and not others cannot be told apart and shows no mark.

After the run, one `check` over the locales it touched sets each locale's `coverage` (`missing`, `stale`, `upToDate`). A locale without it was not measured, and `coverageUnavailable` says the pass failed.

`unreadableRecords` counts lines left out because they could not be read, and the next recorded run rewrites the file without them. `unavailable` (one of `RUN_HISTORY_UNAVAILABLE_REASONS`: `unreadable` or `too-large` above 8 MiB) says the whole file could not be read, so an empty list is then not proof that nothing ran. An unreadable file is left as it is and no record is added to it, and a line that is not valid UTF-8 counts as one unreadable record.
