diff --git a/.changeset/configure-herdr-refresh-intervals.md b/.changeset/configure-herdr-refresh-intervals.md new file mode 100644 index 0000000..9dd986f --- /dev/null +++ b/.changeset/configure-herdr-refresh-intervals.md @@ -0,0 +1,5 @@ +--- +"@leesangb/wt": minor +--- + +Allow Herdr Git diff and pull request refresh intervals to be configured per repository. diff --git a/README.md b/README.md index b54b1d4..a3b4c55 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ GitHub CLI's watch mode and update as soon as they finish. Focusing a Space refreshes it immediately; stable states use a five-minute safety refresh, and stale values expire during an outage. +These intervals can be customized with `herdr.refresh` settings. Values are in +seconds; focused, background, and pull request refreshes have safe minimums of +5, 30, and 60 seconds respectively. Settings are re-read while watchers run, +and focusing a Space always triggers an immediate refresh. + Herdr metadata tokens are display-only, so open the selected Space's pull request with the `wt.herdr.open-pr` action. You can optionally bind it directly: @@ -419,6 +424,9 @@ Edit `.wt/settings.json` in your repository: - **scripts.post**: Array of commands to run after creating worktree (runs in new worktree directory) - **scripts.postMode**: `async` (default) or `sync` for foreground execution - **herdr.closeWorkspaceOnRemove**: Close the associated Herdr workspace after `wt rm` (default: `true`) +- **herdr.refresh.focusedSeconds**: Git diff refresh interval for a focused Herdr Space (default: `30`, minimum: `5`) +- **herdr.refresh.backgroundSeconds**: Git diff refresh interval for a background Herdr Space (default: `300`, minimum: `30`) +- **herdr.refresh.pullRequestSeconds**: Safety refresh interval for stable pull request and CI status (default: `300`, minimum: `60`) - **issue.pattern**: Optional JavaScript regular expression for extracting issue keys from branch names - **issue.url**: Optional issue tracker URL template. Use `$issue` where the extracted key should appear @@ -449,7 +457,12 @@ Example `.wt/settings.local.json`: "postMode": "sync" }, "herdr": { - "closeWorkspaceOnRemove": false + "closeWorkspaceOnRemove": false, + "refresh": { + "focusedSeconds": 15, + "backgroundSeconds": 600, + "pullRequestSeconds": 600 + } } } ``` diff --git a/src/domain/settings.test.ts b/src/domain/settings.test.ts index 72621a6..0e74074 100644 --- a/src/domain/settings.test.ts +++ b/src/domain/settings.test.ts @@ -70,6 +70,35 @@ describe("mergeSettingsInputs", () => { }, }); }); + + test("merges local Herdr refresh overrides without dropping shared intervals", () => { + expect( + mergeSettingsInputs( + { + herdr: { + refresh: { + focusedSeconds: 20, + backgroundSeconds: 240, + }, + }, + }, + { + herdr: { + refresh: { + backgroundSeconds: 600, + }, + }, + } + ) + ).toEqual({ + herdr: { + refresh: { + focusedSeconds: 20, + backgroundSeconds: 600, + }, + }, + }); + }); }); describe("normalizeSettings", () => { @@ -100,6 +129,11 @@ describe("normalizeSettings", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); }); @@ -127,6 +161,11 @@ describe("normalizeSettings", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); }); @@ -154,6 +193,47 @@ describe("normalizeSettings", () => { }).herdr ).toEqual({ closeWorkspaceOnRemove: false, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, + }); + }); + + test("normalizes configurable Herdr refresh intervals in seconds", () => { + expect( + normalizeSettings({ + herdr: { + refresh: { + focusedSeconds: 12, + backgroundSeconds: 90, + pullRequestSeconds: 120, + }, + }, + }).herdr.refresh + ).toEqual({ + focusedSeconds: 12, + backgroundSeconds: 90, + pullRequestSeconds: 120, + }); + }); + + test("clamps Herdr refresh intervals to safe minimums", () => { + expect( + normalizeSettings({ + herdr: { + refresh: { + focusedSeconds: 1, + backgroundSeconds: 2, + pullRequestSeconds: 3, + }, + }, + }).herdr.refresh + ).toEqual({ + focusedSeconds: 5, + backgroundSeconds: 30, + pullRequestSeconds: 60, }); }); @@ -177,6 +257,11 @@ describe("normalizeSettings", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); }); diff --git a/src/domain/settings.ts b/src/domain/settings.ts index 4ccd740..3dd2ef1 100644 --- a/src/domain/settings.ts +++ b/src/domain/settings.ts @@ -16,8 +16,15 @@ export interface WtIssueSettings { url: string; } +export interface WtHerdrRefreshSettings { + focusedSeconds: number; + backgroundSeconds: number; + pullRequestSeconds: number; +} + export interface WtHerdrSettings { closeWorkspaceOnRemove: boolean; + refresh: WtHerdrRefreshSettings; } export interface WtSettings { @@ -31,13 +38,18 @@ export interface WtSettings { } export type WtCopySettingsInput = string[] | Partial; +export type WtHerdrSettingsInput = Partial< + Omit +> & { + refresh?: Partial; +}; export type WtSettingsInput = Partial< Omit > & { copy?: WtCopySettingsInput; scripts?: Partial; - herdr?: Partial; + herdr?: WtHerdrSettingsInput; issue?: Partial; }; @@ -56,6 +68,11 @@ export const DEFAULT_WT_SETTINGS: WtSettings = { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }; @@ -88,6 +105,16 @@ function normalizeIssueInput( }; } +function normalizeRefreshSeconds( + value: number | undefined, + fallback: number, + minimum: number +): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(minimum, Math.floor(value)) + : fallback; +} + export function mergeSettingsInputs( base?: WtSettingsInput | null, override?: WtSettingsInput | null @@ -119,11 +146,19 @@ export function mergeSettingsInputs( ...override?.issue, } : undefined; + const mergedHerdrRefresh = + base?.herdr?.refresh || override?.herdr?.refresh + ? { + ...base?.herdr?.refresh, + ...override?.herdr?.refresh, + } + : undefined; const mergedHerdr = base?.herdr || override?.herdr ? { ...base?.herdr, ...override?.herdr, + ...(mergedHerdrRefresh ? { refresh: mergedHerdrRefresh } : {}), } : undefined; @@ -161,6 +196,23 @@ export function normalizeSettings( closeWorkspaceOnRemove: input?.herdr?.closeWorkspaceOnRemove ?? DEFAULT_WT_SETTINGS.herdr.closeWorkspaceOnRemove, + refresh: { + focusedSeconds: normalizeRefreshSeconds( + input?.herdr?.refresh?.focusedSeconds, + DEFAULT_WT_SETTINGS.herdr.refresh.focusedSeconds, + 5 + ), + backgroundSeconds: normalizeRefreshSeconds( + input?.herdr?.refresh?.backgroundSeconds, + DEFAULT_WT_SETTINGS.herdr.refresh.backgroundSeconds, + 30 + ), + pullRequestSeconds: normalizeRefreshSeconds( + input?.herdr?.refresh?.pullRequestSeconds, + DEFAULT_WT_SETTINGS.herdr.refresh.pullRequestSeconds, + 60 + ), + }, }, ...(issueInput ? { issue: issueInput } : {}), }; diff --git a/src/herdr-plugin/index.test.ts b/src/herdr-plugin/index.test.ts index 937d4de..837b0cd 100644 --- a/src/herdr-plugin/index.test.ts +++ b/src/herdr-plugin/index.test.ts @@ -14,7 +14,9 @@ import { parseWorktreeOpenedEvent, parsePluginContext, parseWtJsonOutput, + pullRequestRefreshIntervalMs, pullRequestStatusToken, + refreshMetadataTtlMs, streamAndCaptureOutput, } from "./index.js"; @@ -227,6 +229,31 @@ describe("wt Herdr plugin", () => { expect(gitDiffRefreshIntervalMs(false)).toBe(300_000); }); + test("uses configured intervals for focused and background Spaces", () => { + const refresh = { + focusedSeconds: 12, + backgroundSeconds: 90, + pullRequestSeconds: 120, + }; + + expect(gitDiffRefreshIntervalMs(true, refresh)).toBe(12_000); + expect(gitDiffRefreshIntervalMs(false, refresh)).toBe(90_000); + }); + + test("uses the configured pull request safety refresh interval", () => { + expect( + pullRequestRefreshIntervalMs({ + focusedSeconds: 12, + backgroundSeconds: 90, + pullRequestSeconds: 120, + }) + ).toBe(120_000); + }); + + test("keeps metadata alive for two configured refresh periods", () => { + expect(refreshMetadataTtlMs(120_000)).toBe(240_000); + }); + test("extracts the opened workspace and checkout from a Herdr event", () => { expect( parseWorktreeOpenedEvent( diff --git a/src/herdr-plugin/index.ts b/src/herdr-plugin/index.ts index 1ee2f93..6eff3f5 100644 --- a/src/herdr-plugin/index.ts +++ b/src/herdr-plugin/index.ts @@ -8,6 +8,12 @@ import { writeFileSync, } from "fs"; import { join } from "path"; +import { + DEFAULT_WT_SETTINGS, + type WtHerdrRefreshSettings, +} from "../domain/settings.js"; +import { getGitRoot } from "../infra/git/repository.js"; +import { loadSettings } from "../infra/storage/settings-store.js"; import { readWorktreeMeta } from "../infra/storage/worktree-meta-store.js"; export type WtMode = "new" | "checkout" | "pr"; @@ -77,11 +83,6 @@ const ANSI_GRAY = "\x1b[90m"; const ANSI_RESET = "\x1b[0m"; const METADATA_SOURCE = "wt:github"; const GIT_METADATA_SOURCE = "wt:git"; -const METADATA_TTL_MS = 600_000; -const GIT_METADATA_TTL_MS = 600_000; -const FOCUSED_GIT_REFRESH_INTERVAL_MS = 30_000; -const BACKGROUND_GIT_REFRESH_INTERVAL_MS = 300_000; -const STABLE_REFRESH_INTERVAL_MS = 300_000; const RETRY_INTERVAL_MS = 60_000; const CHECK_WATCH_INTERVAL_SECONDS = 10; @@ -290,10 +291,29 @@ export function gitDiffStatusToken( return tokens.length > 0 ? tokens.join(" ") : undefined; } -export function gitDiffRefreshIntervalMs(focused: boolean): number { - return focused - ? FOCUSED_GIT_REFRESH_INTERVAL_MS - : BACKGROUND_GIT_REFRESH_INTERVAL_MS; +export function gitDiffRefreshIntervalMs( + focused: boolean, + refresh: WtHerdrRefreshSettings = DEFAULT_WT_SETTINGS.herdr.refresh +): number { + return ( + (focused ? refresh.focusedSeconds : refresh.backgroundSeconds) * 1_000 + ); +} + +export function pullRequestRefreshIntervalMs( + refresh: WtHerdrRefreshSettings = DEFAULT_WT_SETTINGS.herdr.refresh +): number { + return refresh.pullRequestSeconds * 1_000; +} + +export function refreshMetadataTtlMs(refreshIntervalMs: number): number { + return refreshIntervalMs * 2; +} + +async function loadHerdrRefreshSettings( + cwd: string +): Promise { + return (await loadSettings(await getGitRoot(cwd))).herdr.refresh; } export function parseWorktreeOpenedEvent(json: string): WorkspaceWatchTarget { @@ -706,7 +726,8 @@ async function loadCiStatus( async function reportPullRequestStatus( target: WorkspaceWatchTarget, pullRequest: PullRequestDetails, - ci: string | undefined | null + ci: string | undefined | null, + refresh: WtHerdrRefreshSettings ): Promise { const herdr = process.env.HERDR_BIN_PATH ?? "herdr"; const args = [ @@ -721,7 +742,7 @@ async function reportPullRequestStatus( "--seq", String(Date.now()), "--ttl-ms", - String(METADATA_TTL_MS), + String(refreshMetadataTtlMs(pullRequestRefreshIntervalMs(refresh))), ]; if (ci !== null) { args.push(ci ? "--token" : "--clear-token", ci ? `ci=${ci}` : "ci"); @@ -744,7 +765,8 @@ async function loadGitDiffStatus(cwd: string): Promise { async function reportGitDiffStatus( target: WorkspaceWatchTarget, - status: string | undefined + status: string | undefined, + refresh: WtHerdrRefreshSettings ): Promise { const herdr = process.env.HERDR_BIN_PATH ?? "herdr"; const args = [ @@ -759,16 +781,21 @@ async function reportGitDiffStatus( "--seq", String(Date.now()), "--ttl-ms", - String(GIT_METADATA_TTL_MS), + String(refreshMetadataTtlMs(gitDiffRefreshIntervalMs(false, refresh))), ]; await captureWithExitCodes(args, target.cwd, [0]); } async function refreshGitDiffStatus( - target: WorkspaceWatchTarget + target: WorkspaceWatchTarget, + refresh: WtHerdrRefreshSettings ): Promise { - await reportGitDiffStatus(target, await loadGitDiffStatus(target.cwd)); + await reportGitDiffStatus( + target, + await loadGitDiffStatus(target.cwd), + refresh + ); } async function loadWorkspaceState( @@ -887,7 +914,8 @@ function acquireWatcherLock( } async function refreshPullRequestStatus( - target: WorkspaceWatchTarget + target: WorkspaceWatchTarget, + refresh: WtHerdrRefreshSettings ): Promise<{ pullRequest: PullRequestDetails; ci: string | undefined | null }> { const pullRequest = await loadPullRequestDetails(target.cwd); let ci: string | undefined | null; @@ -899,7 +927,7 @@ async function refreshPullRequestStatus( ci = null; } - await reportPullRequestStatus(target, pullRequest, ci); + await reportPullRequestStatus(target, pullRequest, ci, refresh); return { pullRequest, ci }; } @@ -939,7 +967,8 @@ async function watchPullRequestStatus(target: WorkspaceWatchTarget): Promise= gitDiffRefreshIntervalMs(workspace.focused) + now - lastRefreshAt >= + gitDiffRefreshIntervalMs(workspace.focused, refresh) ) { try { - await refreshGitDiffStatus(target); + await refreshGitDiffStatus(target, refresh); lastRefreshAt = Date.now(); consecutiveFailures = 0; } catch { @@ -994,7 +1025,7 @@ async function watchGitDiffStatus( if (consecutiveFailures >= 3) break; } } - await Bun.sleep(FOCUSED_GIT_REFRESH_INTERVAL_MS); + await Bun.sleep(gitDiffRefreshIntervalMs(true, refresh)); } } finally { releaseLock(); @@ -1036,11 +1067,12 @@ async function refreshFocusedWorkspace(): Promise { ); const target = await loadWorkspaceTarget(workspaceId); if (!target) return; + const refresh = await loadHerdrRefreshSettings(target.cwd); - await refreshGitDiffStatus(target); + await refreshGitDiffStatus(target, refresh); const gitRefreshedAt = Date.now(); try { - await refreshPullRequestStatus(target); + await refreshPullRequestStatus(target, refresh); } catch { // Spaces without a linked pull request still report Git changes. } diff --git a/src/infra/storage/settings-store.test.ts b/src/infra/storage/settings-store.test.ts index 0813404..31b7099 100644 --- a/src/infra/storage/settings-store.test.ts +++ b/src/infra/storage/settings-store.test.ts @@ -112,6 +112,11 @@ describe("settings store", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); }); @@ -141,6 +146,11 @@ describe("settings store", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); }); @@ -162,6 +172,13 @@ describe("settings store", () => { post: ["bun install"], postMode: "async", }, + herdr: { + refresh: { + focusedSeconds: 20, + backgroundSeconds: 240, + pullRequestSeconds: 180, + }, + }, }); writeJson(join(wtDir, "settings.local.json"), { baseBranch: "develop", @@ -171,6 +188,11 @@ describe("settings store", () => { scripts: { postMode: "sync", }, + herdr: { + refresh: { + backgroundSeconds: 600, + }, + }, }); await expect(loadSettings(repoRoot)).resolves.toEqual({ @@ -188,6 +210,11 @@ describe("settings store", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 20, + backgroundSeconds: 600, + pullRequestSeconds: 180, + }, }, }); }); @@ -219,6 +246,11 @@ describe("settings store", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); }); @@ -256,6 +288,11 @@ describe("settings store", () => { }, herdr: { closeWorkspaceOnRemove: true, + refresh: { + focusedSeconds: 30, + backgroundSeconds: 300, + pullRequestSeconds: 300, + }, }, }); });