Skip to content

feat(config): support watch key in TOML config#279

Open
mvanhorn wants to merge 1 commit into
modem-dev:mainfrom
mvanhorn:feat/211-watch-config-key
Open

feat(config): support watch key in TOML config#279
mvanhorn wants to merge 1 commit into
modem-dev:mainfrom
mvanhorn:feat/211-watch-config-key

Conversation

@mvanhorn
Copy link
Copy Markdown

Summary

watch = true in ~/.config/hunk/config.toml (or .hunk/config.toml) now actually enables watch mode without needing --watch on every invocation. CLI input still wins -- the global default is unchanged.

Why this matters

#211 asked whether --watch should be the default. The thread surfaced two reasonable directions: flip the global default (risk: surprise users who don't expect content to change under them), or let users opt in via config. ramicats's suggestion in the top community comment (2 upvotes) was the latter: always_watch = true in config. This PR ships that opt-in without making any global-default decision.

The fix turned out to need two changes, not one:

  1. readConfigPreferences (config.ts:56-68) didn't list watch in its returned shape, so a watch = true key in TOML was silently dropped before reaching mergeOptions.
  2. After mergeOptions(resolvedOptions, input.options), the final-override block at config.ts:184-198 explicitly resets watch: input.options.watch ?? false -- hard-resetting any TOML-merged value back to CLI-only sourcing. The same pattern is what makes pager and agentContext CLI-only today.

Without (2), the test "TOML watch = trueoptions.watch === true" would have failed even after the (1) change. Both are required.

Changes

  • src/core/config.ts:
    • readConfigPreferences now reads source.watch via normalizeBoolean.
    • The final override changes from watch: input.options.watch ?? false to watch: input.options.watch ?? resolvedOptions.watch ?? false. CLI explicit wins; otherwise the config-merged value wins; otherwise default false.
  • src/core/config.test.ts: new resolves watch from config unless CLI explicitly enables it covering the full resolution table -- TOML on/off × CLI absent/explicit.
  • README.md: added watch = false to the config example block.

pager and agentContext follow the same final-override pattern and could plausibly be made config-readable in follow-up PRs; deliberately out of scope here to keep this diff minimal and reversible.

Testing

  • bun run typecheck -- clean
  • bun run format:check -- clean
  • bun run lint -- clean (0 warnings)
  • bun test ./src/core/config.test.ts -- 10 pass, 0 fail (the new resolution-table test covers all 4 cells)

The CLI behavior is unchanged when no config sets watch: omitting --watch still means "not watching." The only behavior change is that watch = true in TOML now actually takes effect.

Fixes #211

AI was used for assistance.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 10, 2026

Greptile Summary

This PR enables the watch key in TOML config files to actually take effect, fixing a two-part bug where the key was silently dropped before reaching mergeOptions, and then the final-override block hard-reset it to CLI-only sourcing regardless.

  • src/core/config.ts: readConfigPreferences now reads source.watch via normalizeBoolean, and the final override changes from watch: input.options.watch ?? false to watch: input.options.watch ?? resolvedOptions.watch ?? false, so the TOML-merged value is the fallback when no CLI flag is present.
  • src/core/config.test.ts: Adds a parametric test covering four TOML × CLI resolution combinations to lock in the new behavior.
  • README.md: Adds watch = false to the config example block for documentation.

Confidence Score: 4/5

Safe to merge; the core resolution logic is correct and the four primary TOML x CLI combinations are tested.

The two-line fix to config.ts behaves correctly through the three-pass resolution order. The only gaps are a missing parametric case and a for-loop test structure that hides later failures — neither affects production behavior.

src/core/config.test.ts has a small coverage gap worth filling but nothing that blocks merging.

Important Files Changed

Filename Overview
src/core/config.ts Two-line change: adds watch to readConfigPreferences via normalizeBoolean, and widens the final-override expression. Logic is correct; CLI value still wins via the three-pass resolution order.
src/core/config.test.ts Adds a parametric test covering four TOML×CLI combinations. Coverage is good for the main use cases but misses the CLI-false-overrides-TOML-true cell, and uses a for loop instead of test.each.
README.md Adds watch = false to the config example block; purely documentary, no logic impact.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[resolveConfiguredCliInput called] --> B[Init resolvedOptions]
    B --> C{Global config exists?}
    C -- yes --> D[mergeOptions with global TOML layer]
    C -- no --> E{Repo config exists?}
    D --> E
    E -- yes --> F[mergeOptions with repo TOML layer]
    E -- no --> G[mergeOptions with input.options]
    F --> G
    G --> H[Final override: watch = CLI ?? resolvedOptions.watch ?? false]
    H --> I{CLI watch set?}
    I -- yes --> J[watch = true]
    I -- no --> K{TOML watch set?}
    K -- yes --> L[watch = TOML value]
    K -- no --> M[watch = false]
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/core/config.test.ts:164-208
**Missing test case: TOML watch=true with CLI watch=false**

The resolution table covers four of the five meaningful cells but omits the case `{ config: "watch = true\n", cliOptions: { watch: false }, expected: false }` — CLI explicitly suppressing a config-enabled watch. If `CommonOptions.watch` can legitimately be `false` rather than `undefined` (e.g. a future `--no-watch` flag or a programmatic caller that sets it explicitly), the final override expression `input.options.watch ?? resolvedOptions.watch ?? false` would return `false` correctly, but this is untested. Adding the case would lock in that behavior and make the intent of `??` vs `&&` clear for future readers.

### Issue 2 of 2
src/core/config.test.ts:192-207
**Loop-based parametric test hides later failures on early assertion error**

The `for` loop means that if the first `expect` throws, Bun reports only one failure and skips the remaining three cases. Using `test.each` (or Bun's `describe.each`) would make each cell an independent test so all failing cases surface together — the same pattern would also be more consistent with how the other parametric tests in this project are typically structured.

Reviews (1): Last reviewed commit: "feat(config): support watch key in TOML ..." | Re-trigger Greptile

Comment thread src/core/config.test.ts
Comment on lines +164 to +208
test("resolves watch from config unless CLI explicitly enables it", () => {
const cases: Array<{
config: string;
cliOptions: Partial<CliInput["options"]>;
expected: boolean;
}> = [
{
config: "watch = true\n",
cliOptions: {},
expected: true,
},
{
config: "watch = false\n",
cliOptions: {},
expected: false,
},
{
config: "",
cliOptions: {},
expected: false,
},
{
config: "watch = false\n",
cliOptions: { watch: true },
expected: true,
},
];

for (const entry of cases) {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), entry.config);

const resolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: entry.cliOptions,
},
{ cwd: createTempDir("hunk-config-cwd-"), env: { HOME: home } },
);

expect(resolved.input.options.watch).toBe(entry.expected);
}
});
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.

P2 Missing test case: TOML watch=true with CLI watch=false

The resolution table covers four of the five meaningful cells but omits the case { config: "watch = true\n", cliOptions: { watch: false }, expected: false } — CLI explicitly suppressing a config-enabled watch. If CommonOptions.watch can legitimately be false rather than undefined (e.g. a future --no-watch flag or a programmatic caller that sets it explicitly), the final override expression input.options.watch ?? resolvedOptions.watch ?? false would return false correctly, but this is untested. Adding the case would lock in that behavior and make the intent of ?? vs && clear for future readers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.test.ts
Line: 164-208

Comment:
**Missing test case: TOML watch=true with CLI watch=false**

The resolution table covers four of the five meaningful cells but omits the case `{ config: "watch = true\n", cliOptions: { watch: false }, expected: false }` — CLI explicitly suppressing a config-enabled watch. If `CommonOptions.watch` can legitimately be `false` rather than `undefined` (e.g. a future `--no-watch` flag or a programmatic caller that sets it explicitly), the final override expression `input.options.watch ?? resolvedOptions.watch ?? false` would return `false` correctly, but this is untested. Adding the case would lock in that behavior and make the intent of `??` vs `&&` clear for future readers.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/core/config.test.ts
Comment on lines +192 to +207
for (const entry of cases) {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), entry.config);

const resolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: entry.cliOptions,
},
{ cwd: createTempDir("hunk-config-cwd-"), env: { HOME: home } },
);

expect(resolved.input.options.watch).toBe(entry.expected);
}
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.

P2 Loop-based parametric test hides later failures on early assertion error

The for loop means that if the first expect throws, Bun reports only one failure and skips the remaining three cases. Using test.each (or Bun's describe.each) would make each cell an independent test so all failing cases surface together — the same pattern would also be more consistent with how the other parametric tests in this project are typically structured.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.test.ts
Line: 192-207

Comment:
**Loop-based parametric test hides later failures on early assertion error**

The `for` loop means that if the first `expect` throws, Bun reports only one failure and skips the remaining three cases. Using `test.each` (or Bun's `describe.each`) would make each cell an independent test so all failing cases surface together — the same pattern would also be more consistent with how the other parametric tests in this project are typically structured.

How can I resolve this? If you propose a fix, please make it concise.

@benvinegar
Copy link
Copy Markdown
Member

@mvanhorn If you can address Greptile's comments I think we can smash merge

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.

Discussion: should --watch be the default?

2 participants