Skip to content

fix(cli): re-translate keys whose source value changed in fill - #519

Draft
theluckystrike wants to merge 3 commits into
aymericzip:mainfrom
theluckystrike:fix/intlayer-fill-retranslate
Draft

theluckystrike wants to merge 3 commits into
aymericzip:mainfrom
theluckystrike:fix/intlayer-fill-retranslate

Conversation

@theluckystrike

@theluckystrike theluckystrike commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #513

In complete mode, intlayer fill only sends values that are missing in a target locale. So when a source value is edited, the old translations stay. This PR detects edited source values with git, so no extra file is written or committed.

Before building the tasks, fill reads the previous version of each local content declaration with git show. The ref is HEAD by default, the merge base of --git-diff-base and --git-diff-current when --git-diff is set, and @{push} with --unpushed. Unchanged files are skipped without being loaded. Changed ones are loaded through loadContentDeclaration, which now accepts the file content as an option, so imports and the intlayer alias resolve as usual.

listTranslationsTasks then compares the old and new source values. A target locale gets a task when a source value changed and its translation didn't. If the translation was edited in the same change, it is kept. translateDictionary drops the stale target values before the existing missing-content filter runs, so only changed and missing values go to the AI.

Only the base locale counts as a source, since per-locale files written by fill are outputs. Files that are new, untracked, or outside a git repo behave as before. Markdown and YAML declarations aren't compared.

docs/docs/en/cli/fill.md now says what complete does and which ref it compares with.

What I checked

  • Tests in sourceChanges.test.ts, listTranslationsTasks.test.ts and translateDictionary.test.ts for changed values, translations edited in the same change, per-locale files and the ref choice.
  • vitest run in @intlayer/cli, 205 passed, 19 skipped, 0 failed. The loadDictionaries tests in @intlayer/engine pass.
  • Not run, the full monorepo build and a live fill against an AI provider.

I wrote this with AI assistance and reviewed every changed line by hand.

`intlayer fill` in complete mode only translated locales with missing
values, so editing a source string left every existing translation stale.

Record a hash of each source value a target locale was filled from in
`intlayer.journal.json` at the project root, so the file can be committed
and used in CI. On the next run, values whose hash changed are treated as
missing for that locale and sent for translation again. Unchanged
translations are kept. Locales filled before the journal existed are taken
as up to date on the first run.

Fixes aymericzip#513

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aymericzip

Copy link
Copy Markdown
Owner

Hey @theluckystrike

Thanks for your contribution
I have to say that i'm not a big fan of autogenerated journals in the codebase, that will need to be committed after each change. It will create constant conflicts for teams working on a same project.
I would love to get something similar, without a journal

…e journal

The journal file had to be committed after each fill and would conflict
between team members. Fill now reads the previous version of each content
declaration from git instead. It compares with HEAD by default, with the
merge base of the base branch when --git-diff is set, and with the upstream
when --unpushed is set.

A source value that changed while its translation did not is sent for
translation again. A translation edited in the same change is kept. Nothing
extra is written to the project.

loadContentDeclaration and loadExternalFile accept the file content as an
option so the git version can be loaded with the same imports and aliases.
@theluckystrike

Copy link
Copy Markdown
Contributor Author

Makes sense. I removed the journal. Fill now reads the previous version of each content declaration from git and compares the source values.

It uses HEAD by default, the merge base with --git-diff, and the upstream with --unpushed. A value is re-translated only when its source changed and its translation didn't, so nothing extra gets written or committed. Head is 8edd334.

@aymericzip aymericzip 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.

Changes seems good @theluckystrike, nothing to say regarding the code. Good job
I will have to test it properly before we merge

I also saw that .yml, .yaml, .md & .mdx are covered, which is great

Few notes to study,

  • Is that approach will work in CI (e.g. Github Action)?
  • What if git is not initialized on the project?
  • Will it cover content loaded via syncJSON / loadJSON plugins & syncPO / loadPO plugins
  • No breaking changes are present with remote dictionaries, considering both remote & locale&remote locations

…ailable

Fill already fell back to filling missing translations when git could not
give the previous version of a file, but it did so silently. It now checks
the ref once before reading any file and logs the reason: no git repository,
no commit, no upstream branch, or no merge base with --git-diff (for example
a shallow clone from actions/checkout with its default fetch-depth: 1).

Adds hasGitRef to the engine git helpers, with tests against real
repositories (no repository, no commit, shallow clone), and a test that
remote and plugin dictionaries are never read from git.
@theluckystrike

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I went through each point in the code, then tried them on throwaway git repos.

  1. CI: it works with --git-diff and the full history, which the workflow in the CI/CD doc already has (fetch-depth: 0). On a pull_request checkout the merge base of origin/main and the PR merge commit is the main tip, so the old file versions come from there. With the default fetch-depth: 1 there's no origin/main and no merge base, so fill only fills missing translations, as before.

    Without --git-diff, fill compares with HEAD. A fresh checkout equals HEAD, so CI won't re-translate anything extra. CI note: use fetch-depth: 0 with --git-diff.

  2. No git: each git call is in a try/catch and returns undefined, so fill falls back to the old behaviour (missing translations only). That fallback was silent, though. f3ad740 checks the ref once with a new hasGitRef helper and logs why source changes aren't checked: no repository, no commit, no upstream, or no merge base (with the fetch-depth: 0 hint).

    It's a warning when a git flag is set and an info line otherwise. The new tests run real git against a folder with no repository, a repository without commits, and a shallow clone.

  3. syncJSON / loadJSON and syncPO / loadPO: not covered. Their dictionaries carry a plugin location (sync-json::<pattern>, sync-po::<pattern>, plugin or a custom one), and only local and hybrid dictionaries are read from git. So they keep the current behaviour.

    Covering them means parsing the git version of the plugin's source file, splitKeys included. One option is an optional hook in the sync plugin kit that turns file content into dictionaries, which fill would call with the git show output. I can do it in a follow-up PR if you want it.

  4. remote and hybrid (local and remote): nothing changes for remote. Those dictionaries, and the copies fetched from the CMS (also tagged remote), are filtered out before any git call, so their tasks are built as before. A hybrid content declaration file is compared with git like a local one, and the write path is the same. A new test covers the filter: local and hybrid are read from git, remote, plugin and sync-json aren't.

One correction on .yml, .yaml, .md and .mdx. They still fill as before, but source change detection only reads the git version of JSON, JS and TS declarations, as the fill doc says. YAML would be a small follow-up.

Tests: cli fill 49 passed, engine git helpers 4 passed. I haven't run a live fill against an AI provider.

@aymericzip

Copy link
Copy Markdown
Owner

New note @theluckystrike

  • If the user commit twice on main, and running the gitdiff HEAD vs HEAD, the change will be missed
  • and loadPreviousDictionaries only handles content declaration files, but plugins dictionaries should be handled too

It's a great work, but I'm not 100% satisfied by the suggested change. I can't provide a feature that does only work in some specifics cases.

After going deeper on it, the best way to implement it will probably be to use journals connected to the CMS & Intlayer backend
Please let's study it properly before going to further implementation

@aymericzip
aymericzip marked this pull request as draft October 6, 2026 15:15
@theluckystrike

Copy link
Copy Markdown
Contributor Author

Both points are right, thanks for checking them.

On the first one: with two commits already on main, --git-diff compares HEAD with itself, so the second change is never seen.

On the second: loadPreviousDictionaries only reads content declaration files, and anything loaded through plugins like syncJSON or loadJSON is skipped.

Agreed that a feature that only works in some setups is not good enough. I'll keep this as a draft and won't push more code. Once you have settled on how the journal should connect to the CMS and backend, I can rework it on top of that, or close this PR if a fresh one is easier.

@aymericzip

Copy link
Copy Markdown
Owner

Thanks @theluckystrike

I will keep you updated once we will have a clearer vision on that feature

@theluckystrike

Copy link
Copy Markdown
Contributor Author

@aymericzip, sounds good, thanks. I'll keep #519 current with main until the direction is clearer, then adapt it to whatever you settle on.

In the meantime #521 is a small docs fix. It removes the output-content-declarations flag from the extract docs, since the CLI doesn't have it.

Thank you,
Michael Lip
Founder of https://zovo.one, 108 Chrome extensions with 150,000+ users
Also building https://makeroll.com and https://ml0x.com, open-source guardrails for Claude Code agents
Open source has to win, and small fixes like this one are my part of it

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.

intlayer fill does not re-translate keys whose source value changed

2 participants