Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cancellable-shared-history-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Keep commit reviews responsive while bundled VCS commands run, and share one correctly owned extension lifecycle across retained history and embedded reviews.
5 changes: 5 additions & 0 deletions .changeset/persistent-history-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Keep `hunk log` and opened commit reviews in one terminal renderer so returning never exposes previous terminal output.
22 changes: 22 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,22 @@ start watchers, processes, connections, and other long-lived resources from
`startup`, and release them from `shutdown`. Extension-registry reloads create
new instances and run that shutdown/startup pair around the replacement.

An interactive history workspace owns one extension instance for its complete
lifetime. Opening a commit review inside that workspace borrows the same
instance: the factory and `startup` do not run again, and returning to history
does not send `shutdown`. Review-specific `changeset_loaded` events still run
for each opened commit. The owning history workspace sends the one eventual
`shutdown` when it exits. This makes module-local clients and stores safe to
share deliberately between retained history and its commit reviews without a
review closing resources that history still uses.

Keep mutable review-generation data keyed by the identities in event payloads
or replace it on `changeset_loaded`; module scope is workspace state, not a
fresh namespace per opened commit. An embedded review cannot replace its
borrowed registry; extension replacement remains an operation of the owning
workspace. A true owner-driven extension-registry reload creates a new instance
and retires the replaced instance at that explicit ownership boundary.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `19`). Branch on it if you want
Expand Down Expand Up @@ -562,6 +578,12 @@ reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks,
and native merge-review semantics without routing through a colocated Git repository. Third-party
adapters use exactly the same contract.

Every operation `load` receives `context.signal`. Use asynchronous subprocess
APIs, pass cancellation through, and terminate plus reap provider processes when
it aborts; a synchronous spawn blocks Hunk's renderer and prevents the abort
handler from running. Watch signatures remain synchronous because the watch
runtime calls them as short, noninteractive probes.

A `load` result is patch text plus how to label it. Everything else on it is
optional, and each optional field buys one thing:

Expand Down
60 changes: 60 additions & 0 deletions src/app/extensionBootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import type { HunkConfigResolution } from "../core/run/config";
import type { CliInput } from "../core/run/commandInputs";
import type { HunkExtensionAPI } from "../extension-api/types";
import { emitExtensionEvent, retireExtensionLoadResult } from "../extensions/events";
import { loadExtensions } from "../extensions/host";
import { createEmptyExtensionLoadResult } from "../extensions/types";
import { resolveConfiguredExtensions } from "./extensionBootstrap";
import { getBundledVcsCatalog } from "./vcsCatalog";
Expand All @@ -16,6 +19,63 @@ function createTestConfig(input: CliInput): HunkConfigResolution {
}

describe("resolveConfiguredExtensions", () => {
test("borrows one stateful extension instance across repeated review generations", async () => {
const input: CliInput = { kind: "show", ref: "opaque", options: { vcs: "git" } };
const state = { factories: 0, startups: 0, changesets: 0, shutdowns: 0 };
const borrowed = await loadExtensions({
candidates: [{ id: "stateful", path: "/repo/stateful.ts", origin: "flag" }],
cwd: "/repo",
importExtensionModuleImpl: async () => ({
default(hunk: HunkExtensionAPI) {
state.factories += 1;
hunk.on("startup", () => {
state.startups += 1;
});
hunk.on("changeset_loaded", () => {
state.changesets += 1;
});
hunk.on("shutdown", () => {
state.shutdowns += 1;
});
},
}),
});
emitExtensionEvent(borrowed, "startup", { cwd: "/repo" });
let loaderCalls = 0;

for (let generation = 0; generation < 2; generation += 1) {
const resolved = await resolveConfiguredExtensions(
{
runtimeInput: input,
configured: createTestConfig(input),
cwd: "/repo",
baseVcsCatalog: getBundledVcsCatalog(),
borrowedLoad: borrowed,
},
{
loadStartupExtensionsImpl: async () => {
loaderCalls += 1;
throw new Error("borrowed extensions must not reload");
},
},
);
expect(resolved.extensions).toBe(borrowed);
emitExtensionEvent(resolved.extensions, "changeset_loaded", {
changeset: {
id: `generation-${generation}`,
title: "Stateful review",
files: [],
sourceLabel: `generation-${generation}`,
},
});
}

expect(state).toEqual({ factories: 1, startups: 1, changesets: 2, shutdowns: 0 });
expect(loaderCalls).toBe(0);
await retireExtensionLoadResult(borrowed);
expect(state.shutdowns).toBe(1);
});

test("retires provisional authority when the loader rejects before returning it", async () => {
const input: CliInput = { kind: "vcs", staged: false, options: { vcs: "git" } };
const provisional = createEmptyExtensionLoadResult("/repo");
Expand Down
20 changes: 20 additions & 0 deletions src/app/extensionBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface ResolveConfiguredExtensionsOptions {
notifications?: ExtensionNotificationHub;
/** Registry already loaded by an extension CLI command before built-in delegation. */
previousLoad?: ExtensionLoadResult;
/** Session-owned registry borrowed by an embedded surface without lifecycle authority. */
borrowedLoad?: ExtensionLoadResult;
/** Publish provisional ownership before imports or asynchronous factories can suspend. */
onProvisionalLoad?: (result: ExtensionLoadResult) => void;
/** Throw when the caller's lifetime ended so no later staged registry can be created. */
Expand Down Expand Up @@ -59,6 +61,24 @@ export async function resolveConfiguredExtensions(
vcsCatalog: options.discoveryCatalog ?? options.baseVcsCatalog,
});

if (options.borrowedLoad) {
const adapters = resolveExtensionVcsAdapters(
options.borrowedLoad.registry,
options.baseVcsCatalog,
).adapters;
const catalog = extendVcsCatalog(options.baseVcsCatalog, adapters);
const projectRoot = findProjectRootCandidateImpl(options.cwd, catalog);
if (projectRoot !== configured.projectRoot) {
configured = resolveConfiguredCliInputImpl(options.runtimeInput, {
cwd: options.cwd,
env: options.env,
vcsCatalog: catalog,
});
}
options.assertActive?.();
return { configured, extensions: options.borrowedLoad };
}

let extensions: ExtensionLoadResult | undefined;
let provisionalExtensions: ExtensionLoadResult | undefined;
/** Retain loader ownership locally before forwarding it to a caller that may also suspend. */
Expand Down
5 changes: 5 additions & 0 deletions src/app/historyBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ export interface HistoryBootstrap {
source: VcsHistorySource;
providerId: string;
providerName: string;
startupCwd: string;
repoRoot: string;
extensions: ExtensionLoadResult;
/** History-owned extension authority borrowed by embedded reviews. */
extensionSession: ExtensionLoadResult;
notices: readonly string[];
customThemes: readonly NamedCustomThemeConfig[];
planReview(
Expand Down Expand Up @@ -129,8 +132,10 @@ export async function loadHistoryBootstrap({
source,
providerId: sanitizeTerminalLine(adapter.id),
providerName: sanitizeTerminalLine(adapter.name),
startupCwd: cwd,
repoRoot,
extensions: resolved.extensions,
extensionSession: resolved.extensions,
customThemes: sessionThemes.themes,
notices: [
...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map(
Expand Down
6 changes: 6 additions & 0 deletions src/app/session/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ describe("session registration", () => {
// Intent: the daemon can address every resource of the generation it mirrors, and can
// map the renderer file ids the session surface uses onto the semantic keys resources
// are addressed by.
test("createSessionRegistration accepts the owning surface's authoritative cwd", () => {
const bootstrap = createBootstrap();
const registration = createSessionRegistration(bootstrap, publish(bootstrap), "embedded-cwd");
expect(registration.cwd).toBe("embedded-cwd");
});

test("createSessionRegistration advertises the generation's resource catalog", () => {
const bootstrap = createBootstrap();
const publication = publish(bootstrap);
Expand Down
3 changes: 2 additions & 1 deletion src/app/session/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,15 @@ function buildReviewCatalog(publication: ReviewPublication): HunkReviewResourceC
export function createSessionRegistration(
bootstrap: AppBootstrap,
publication: ReviewPublication,
cwd = process.cwd(),
): HunkSessionRegistration {
const terminal = resolveSessionTerminalMetadata({ tty: ttyname() });

return {
registrationVersion: SESSION_BROKER_REGISTRATION_VERSION,
sessionId: randomUUID(),
pid: process.pid,
cwd: process.cwd(),
cwd,
repoRoot: inferRepoRoot(bootstrap),
launchedAt: new Date().toISOString(),
terminal,
Expand Down
7 changes: 7 additions & 0 deletions src/app/sessionBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface SessionBootstrapOptions {
loadAppBootstrapImpl?: typeof loadAppBootstrap;
/** Base product adapters composed before user extensions are applied. */
baseVcsCatalog?: VcsCatalog;
/** Cancel initial provider-backed loading for an abandoned embedded surface. */
signal?: AbortSignal;
}

export interface SessionBootstrapResult {
Expand Down Expand Up @@ -57,7 +59,9 @@ export async function loadConfiguredSessionBootstrap({
loadAtCwd = false,
loadAppBootstrapImpl = loadAppBootstrap,
baseVcsCatalog = getBundledVcsCatalog(),
signal,
}: SessionBootstrapOptions): Promise<SessionBootstrapResult> {
signal?.throwIfAborted();
const previousFileLanguages = fileLanguageRegistrationSnapshot();

try {
Expand All @@ -84,8 +88,11 @@ export async function loadConfiguredSessionBootstrap({
...(loadAtCwd ? { cwd } : {}),
customThemes: sessionThemes.themes,
vcsCatalog: applied.vcsCatalog,
signal,
})) as AppBootstrap;
signal?.throwIfAborted();
bootstrap.changeset = await applyExtensionChangesetTransforms(extensions, bootstrap.changeset);
signal?.throwIfAborted();
bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode;
bootstrap.extensions = extensions;
bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath;
Expand Down
7 changes: 2 additions & 5 deletions src/app/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ describe("startup planning", () => {
expect(opened).toBe(1);
});

test("inherits handoff theme mode without querying the parent-owned terminal", async () => {
test("inherits an embedded renderer theme mode without querying the shared terminal", async () => {
const cliInput: CliInput = {
kind: "show",
ref: "opaque:id",
Expand All @@ -768,10 +768,7 @@ describe("startup planning", () => {
},
stdinIsTTY: true,
stdoutIsTTY: true,
env: {
HUNK_TERMINAL_HANDOFF: "1",
HUNK_TERMINAL_HANDOFF_THEME_MODE: "dark",
},
terminalThemeMode: "dark",
});

expect(plan).toMatchObject({ kind: "app", bootstrap: { initialThemeMode: "dark" } });
Expand Down
33 changes: 25 additions & 8 deletions src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { resolveConfiguredCliInput } from "../core/run/config";
import { HunkUserError } from "../core/run/errors";
import type { loadAppBootstrap } from "../core/changeset/loaders";
import { looksLikePatchInput } from "../core/process/pager";
import { terminalHandoffThemeMode } from "../core/process/terminalHandoff";
import { sanitizeTerminalText } from "../lib/terminalText";
import { detectTerminalThemeModeFromBackground } from "../core/theme/detection";
import {
Expand Down Expand Up @@ -136,6 +135,14 @@ export interface StartupDeps {
runExtensionCliCommandImpl?: typeof import("../extensions/cliCommandRuntime").runExtensionCliCommand;
env?: NodeJS.ProcessEnv;
bunVersion?: string;
/** Override process cwd for an embedded review bootstrap. */
cwd?: string;
/** Reuse the owning renderer's detected terminal mode. */
terminalThemeMode?: "dark" | "light";
/** Cancel provider-backed startup for an abandoned embedded surface. */
signal?: AbortSignal;
/** Borrow the owning history session's already-loaded extension authority. */
borrowedExtensionLoad?: import("../extensions/types").ExtensionLoadResult;
}

/** Carry the invocation's authoritative extension paths into a delegated review input. */
Expand Down Expand Up @@ -197,17 +204,20 @@ export async function prepareStartupPlan(
const env = deps.env ?? process.env;
const bunVersion = deps.bunVersion ?? Bun.version;
const loadBaseVcsCatalog = createBundledVcsCatalogLoader();
const startupCwd = process.cwd();
const startupCwd = deps.cwd ?? process.cwd();
deps.signal?.throwIfAborted();

let parsedCliInput = await parseCliImpl(argv);
let controllingTerminal: ControllingTerminal | null = null;
let preloadedExtensions: import("../extensions/types").ExtensionLoadResult | undefined;
let preloadedExtensions: import("../extensions/types").ExtensionLoadResult | undefined =
deps.borrowedExtensionLoad;
const ownsPreloadedExtensions = !deps.borrowedExtensionLoad;
let delegatedDiscoveryCatalog: VcsCatalog | undefined;
let delegatedReview: ExtensionReviewDescriptor | undefined;

/** Retire startup-owned extension state before returning a non-app plan. */
const retirePreloadedExtensions = async () => {
if (!preloadedExtensions) return;
if (!preloadedExtensions || !ownsPreloadedExtensions) return;
await (await import("../extensions/events")).retireExtensionLoadResult(preloadedExtensions);
preloadedExtensions = undefined;
};
Expand Down Expand Up @@ -544,9 +554,9 @@ export async function prepareStartupPlan(
controllingTerminal = openControllingTerminalImpl();
}

// A handoff child inherits the parent's detected mode so bootstrap never queries a terminal
// whose input and renderer are still exclusively owned by the history process.
let initialThemeMode: AppBootstrap["initialThemeMode"] = terminalHandoffThemeMode(env);
// Embedded reviews inherit their owner's detected mode so bootstrap never queries a terminal
// whose input and renderer are already exclusively owned.
let initialThemeMode: AppBootstrap["initialThemeMode"] = deps.terminalThemeMode;
if (!initialThemeMode && cliInput.options.theme === "auto" && stdoutIsTTY) {
const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null);
if (themeInput) {
Expand Down Expand Up @@ -586,14 +596,20 @@ export async function prepareStartupPlan(
env,
baseVcsCatalog,
discoveryCatalog: delegatedDiscoveryCatalog,
previousLoad: preloadedExtensions,
previousLoad: deps.borrowedExtensionLoad ? undefined : preloadedExtensions,
borrowedLoad: deps.borrowedExtensionLoad,
assertActive: () => deps.signal?.throwIfAborted(),
},
{ resolveConfiguredCliInputImpl, loadStartupExtensionsImpl },
);
configured = resolvedExtensions.configured;
cliInput = configured.input;
const extensionResult = resolvedExtensions.extensions;
preloadedExtensions = extensionResult;
if (deps.signal?.aborted) {
await retirePreloadedExtensions();
deps.signal.throwIfAborted();
}

let preparedSession: SessionBootstrapResult;
try {
Expand All @@ -604,6 +620,7 @@ export async function prepareStartupPlan(
initialThemeMode,
loadAppBootstrapImpl,
baseVcsCatalog,
signal: deps.signal,
});
} catch (error) {
controllingTerminal?.close();
Expand Down
13 changes: 11 additions & 2 deletions src/core/changeset/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ afterEach(() => {
});

describe("loadAppBootstrap", () => {
test("refuses an already-cancelled embedded review load", async () => {
const abort = new AbortController();
abort.abort();
await expect(
loadAppBootstrap({ kind: "show", ref: "opaque", options: {} }, { signal: abort.signal }),
).rejects.toThrow();
});

test("synthesizes untracked file diffs an adapter reported by path", async () => {
const dir = createTempDir("hunk-adapter-untracked-");
writeFileSync(join(dir, "note.txt"), "hello\n");
Expand Down Expand Up @@ -1932,8 +1940,9 @@ describe("loadAppBootstrap source fetcher attachment", () => {
mutableBun.spawn = originalSpawn;
}

expect(syncCalls.some((call) => call.includes("rev-parse"))).toBe(true);
expect(syncCalls.some((call) => call.includes("diff"))).toBe(true);
expect(syncCalls).toEqual([]);
expect(asyncCalls.some((call) => call.includes("rev-parse"))).toBe(true);
expect(asyncCalls.some((call) => call.includes("diff"))).toBe(true);
expect(asyncCalls).toContainEqual([gitExecutable, "show", ":value.txt"]);
});

Expand Down
Loading
Loading