diff --git a/src/core/agent.ts b/src/core/agent.ts index 19885fc..87fabd9 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,5 +1,10 @@ +import { resolve as resolvePath } from "node:path"; import type { AgentContext, AgentFileContext } from "./types"; +interface AgentContextLoadOptions { + cwd?: string; +} + /** Normalize one file entry from the optional agent-context sidecar JSON. */ function normalizeAnnotationFile(file: unknown): AgentFileContext { if (!file || typeof file !== "object") { @@ -78,7 +83,10 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext { } /** Load the optional agent-context sidecar from a file path or stdin. */ -export async function loadAgentContext(pathOrDash?: string): Promise { +export async function loadAgentContext( + pathOrDash?: string, + { cwd = process.cwd() }: AgentContextLoadOptions = {}, +): Promise { if (!pathOrDash) { return null; } @@ -86,7 +94,7 @@ export async function loadAgentContext(pathOrDash?: string): Promise; diff --git a/src/core/cli.ts b/src/core/cli.ts index e086e85..cdcb003 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -228,6 +228,53 @@ function resolveExplicitSessionSelector( return sessionId ? { sessionId } : { repoRoot: resolve(repoRoot!) }; } +function resolveReloadSelector( + sessionId: string | undefined, + sessionPath: string | undefined, + repoRoot: string | undefined, + sourcePath: string | undefined, +) { + if (sessionPath && repoRoot) { + throw new Error( + "Specify either --session-path or --repo as the target, not both.", + ); + } + + if (sessionId && sessionPath) { + throw new Error("Specify either or --session-path , not both."); + } + + if (sessionId && repoRoot) { + throw new Error("Specify either or --repo , not both."); + } + + const resolvedSource = sourcePath ? resolve(sourcePath) : undefined; + if (sessionId) { + return { + selector: { sessionId }, + sourcePath: resolvedSource, + }; + } + + if (sessionPath) { + return { + selector: { sessionPath: resolve(sessionPath) }, + sourcePath: resolvedSource, + }; + } + + if (repoRoot) { + return { + selector: { repoRoot: resolve(repoRoot) }, + sourcePath: resolvedSource, + }; + } + + throw new Error( + "Specify one live Hunk session with or --repo (or --session-path ).", + ); +} + /** Parse the overloaded `hunk diff` command. */ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); @@ -453,13 +500,13 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session get --repo ", " hunk session context ", " hunk session context --repo ", - " hunk session navigate --file (--hunk | --old-line | --new-line )", - " hunk session reload -- diff [ref] [-- ]", - " hunk session reload -- show [ref] [-- ]", - " hunk session comment add --file (--old-line | --new-line ) --summary ", - " hunk session comment list ", - " hunk session comment rm ", - " hunk session comment clear --yes", + " hunk session navigate ( | --repo ) --file (--hunk | --old-line | --new-line )", + " hunk session reload ( | --repo | --session-path ) [--source ] -- diff [ref] [-- ]", + " hunk session reload ( | --repo | --session-path ) [--source ] -- show [ref] [-- ]", + " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ", + " hunk session comment list ( | --repo )", + " hunk session comment rm ( | --repo ) ", + " hunk session comment clear ( | --repo ) --yes", ].join("\n") + "\n", }; } @@ -598,15 +645,23 @@ async function parseSessionCommand(tokens: string[]): Promise { .description("replace the contents of one live Hunk session") .argument("[sessionId]") .option("--repo ", "target the live session whose repo root matches this path") + .option("--session-path ", "target a live session rooted at a different path") + .option("--source ", "load the diff from this directory instead of the session's own") .option("--json", "emit structured JSON"); let parsedSessionId: string | undefined; - let parsedOptions: { repo?: string; json?: boolean } = {}; + let parsedOptions: { sessionPath?: string; repo?: string; source?: string; json?: boolean } = + {}; - command.action((sessionId: string | undefined, options: { repo?: string; json?: boolean }) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + command.action( + ( + sessionId: string | undefined, + options: { sessionPath?: string; repo?: string; source?: string; json?: boolean }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); if (outerTokens.includes("--help") || outerTokens.includes("-h")) { return { @@ -618,6 +673,7 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session reload --repo . -- diff", " hunk session reload --repo . -- diff main...feature -- src/ui", " hunk session reload --repo . -- show HEAD~1 -- README.md", + " hunk session reload --session-path /path/to/session --source /path/to/repo -- diff", ].join("\n") + "\n", }; @@ -638,12 +694,19 @@ async function parseSessionCommand(tokens: string[]): Promise { await parseStandaloneCommand(command, outerTokens); const nextInput = requireReloadableCliInput(await parseCli(["bun", "hunk", ...nestedTokens])); + const resolvedReload = resolveReloadSelector( + parsedSessionId, + parsedOptions.sessionPath, + parsedOptions.repo, + parsedOptions.source, + ); return { kind: "session", action: "reload", output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + selector: resolvedReload.selector, + sourcePath: resolvedReload.sourcePath, nextInput, }; } @@ -829,12 +892,22 @@ async function parseSessionCommand(tokens: string[]): Promise { .option("--json", "emit structured JSON"); let parsedSessionId: string | undefined; - let parsedOptions: { repo?: string; file?: string; yes?: boolean; json?: boolean } = {}; + let parsedOptions: { + repo?: string; + file?: string; + yes?: boolean; + json?: boolean; + } = {}; command.action( ( sessionId: string | undefined, - options: { repo?: string; file?: string; yes?: boolean; json?: boolean }, + options: { + repo?: string; + file?: string; + yes?: boolean; + json?: boolean; + }, ) => { parsedSessionId = sessionId; parsedOptions = options; diff --git a/src/core/loaders.ts b/src/core/loaders.ts index 967904e..0bcf8cf 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -6,6 +6,7 @@ import { type FileDiffMetadata, } from "@pierre/diffs"; import { createTwoFilesPatch } from "diff"; +import { resolve as resolvePath } from "node:path"; import { findAgentFileContext, loadAgentContext } from "./agent"; import { buildGitDiffArgs, @@ -30,6 +31,10 @@ import type { StashShowCommandInput, } from "./types"; +interface LoadAppBootstrapOptions { + cwd?: string; +} + /** Return the final path segment for display-oriented labels. */ function basename(path: string) { return path.split("/").filter(Boolean).pop() ?? path; @@ -322,9 +327,12 @@ function normalizePatchChangeset( async function loadFileDiffChangeset( input: FileCommandInput | DiffToolCommandInput, agentContext: AgentContext | null, + cwd = process.cwd(), ) { - const leftText = await Bun.file(input.left).text(); - const rightText = await Bun.file(input.right).text(); + const leftPath = resolvePath(cwd, input.left); + const rightPath = resolvePath(cwd, input.right); + const leftText = await Bun.file(leftPath).text(); + const rightText = await Bun.file(rightPath).text(); const displayPath = input.kind === "difftool" ? (input.path ?? basename(input.right)) : basename(input.right); const title = @@ -337,12 +345,12 @@ async function loadFileDiffChangeset( const oldFile: FileContents = { name: displayPath, contents: leftText, - cacheKey: `${input.left}:left`, + cacheKey: `${leftPath}:left`, }; const newFile: FileContents = { name: displayPath, contents: rightText, - cacheKey: `${input.right}:right`, + cacheKey: `${rightPath}:right`, }; const metadata = parseDiffFromFile(oldFile, newFile, { context: 3 }, true); @@ -360,8 +368,12 @@ async function loadFileDiffChangeset( } /** Build a changeset from the current repository working tree or a git range. */ -async function loadGitChangeset(input: GitCommandInput, agentContext: AgentContext | null) { - const repoRoot = resolveGitRepoRoot(input); +async function loadGitChangeset( + input: GitCommandInput, + agentContext: AgentContext | null, + cwd = process.cwd(), +) { + const repoRoot = resolveGitRepoRoot(input, { cwd }); const repoName = basename(repoRoot); const title = input.staged ? `${repoName} staged changes` @@ -369,7 +381,7 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte ? `${repoName} ${input.range}` : `${repoName} working tree`; const trackedChangeset = normalizePatchChangeset( - runGitText({ input, args: buildGitDiffArgs(input) }), + runGitText({ input, args: buildGitDiffArgs(input), cwd }), title, repoRoot, agentContext, @@ -400,12 +412,16 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte } /** Build a changeset from `git show`, suppressing commit-message chrome so only the patch feeds the UI. */ -async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentContext | null) { - const repoRoot = resolveGitRepoRoot(input); +async function loadShowChangeset( + input: ShowCommandInput, + agentContext: AgentContext | null, + cwd = process.cwd(), +) { + const repoRoot = resolveGitRepoRoot(input, { cwd }); const repoName = basename(repoRoot); return normalizePatchChangeset( - runGitText({ input, args: buildGitShowArgs(input) }), + runGitText({ input, args: buildGitShowArgs(input), cwd }), input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`, repoRoot, agentContext, @@ -416,12 +432,13 @@ async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentCon async function loadStashShowChangeset( input: StashShowCommandInput, agentContext: AgentContext | null, + cwd = process.cwd(), ) { - const repoRoot = resolveGitRepoRoot(input); + const repoRoot = resolveGitRepoRoot(input, { cwd }); const repoName = basename(repoRoot); return normalizePatchChangeset( - runGitText({ input, args: buildGitStashShowArgs(input) }), + runGitText({ input, args: buildGitStashShowArgs(input), cwd }), input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`, repoRoot, agentContext, @@ -429,12 +446,16 @@ async function loadStashShowChangeset( } /** Build a changeset from patch text supplied by file or stdin. */ -async function loadPatchChangeset(input: PatchCommandInput, agentContext: AgentContext | null) { +async function loadPatchChangeset( + input: PatchCommandInput, + agentContext: AgentContext | null, + cwd = process.cwd(), +) { const patchText = input.text ?? (!input.file || input.file === "-" ? await new Response(Bun.stdin.stream()).text() - : await Bun.file(input.file).text()); + : await Bun.file(resolvePath(cwd, input.file)).text()); const label = input.file && input.file !== "-" ? input.file : "stdin patch"; return normalizePatchChangeset( @@ -446,29 +467,32 @@ async function loadPatchChangeset(input: PatchCommandInput, agentContext: AgentC } /** Resolve CLI input into the fully loaded app bootstrap state. */ -export async function loadAppBootstrap(input: CliInput): Promise { - const agentContext = await loadAgentContext(input.options.agentContext); +export async function loadAppBootstrap( + input: CliInput, + { cwd = process.cwd() }: LoadAppBootstrapOptions = {}, +): Promise { + const agentContext = await loadAgentContext(input.options.agentContext, { cwd }); let changeset: Changeset; switch (input.kind) { case "git": - changeset = await loadGitChangeset(input, agentContext); + changeset = await loadGitChangeset(input, agentContext, cwd); break; case "show": - changeset = await loadShowChangeset(input, agentContext); + changeset = await loadShowChangeset(input, agentContext, cwd); break; case "stash-show": - changeset = await loadStashShowChangeset(input, agentContext); + changeset = await loadStashShowChangeset(input, agentContext, cwd); break; case "diff": - changeset = await loadFileDiffChangeset(input, agentContext); + changeset = await loadFileDiffChangeset(input, agentContext, cwd); break; case "patch": - changeset = await loadPatchChangeset(input, agentContext); + changeset = await loadPatchChangeset(input, agentContext, cwd); break; case "difftool": - changeset = await loadFileDiffChangeset(input, agentContext); + changeset = await loadFileDiffChangeset(input, agentContext, cwd); break; } diff --git a/src/core/types.ts b/src/core/types.ts index 545d974..280da6d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -90,6 +90,7 @@ export type SessionCommandOutput = "text" | "json"; export interface SessionSelectorInput { sessionId?: string; + sessionPath?: string; repoRoot?: string; } @@ -123,6 +124,7 @@ export interface SessionReloadCommandInput { output: SessionCommandOutput; selector: SessionSelectorInput; nextInput: CliInput; + sourcePath?: string; } export interface SessionCommentAddCommandInput { diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index 1160116..67d9bd1 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -40,6 +40,7 @@ interface SessionEntry { export interface SessionTargetSelector { sessionId?: string; + sessionPath?: string; repoRoot?: string; } @@ -69,6 +70,23 @@ export function resolveSessionTarget(sessions: ListedSession[], selector: Sessio return matched; } + const sessionPath = selector.sessionPath; + if (sessionPath) { + const matches = sessions.filter((session) => session.cwd === sessionPath); + if (matches.length === 0) { + throw new Error(`No active Hunk session matches session path ${sessionPath}.`); + } + + if (matches.length > 1) { + throw new Error( + `Multiple active Hunk sessions match session path ${sessionPath}; specify sessionId instead. ` + + `Matches: ${describeSessionChoices(matches)}.`, + ); + } + + return matches[0]!; + } + if (selector.repoRoot) { const matches = sessions.filter((session) => session.repoRoot === selector.repoRoot); if (matches.length === 0) { @@ -96,7 +114,7 @@ export function resolveSessionTarget(sessions: ListedSession[], selector: Sessio } throw new Error( - `Multiple active Hunk sessions are registered; specify sessionId or repoRoot. ` + + `Multiple active Hunk sessions are registered; specify sessionId, sessionPath, or repoRoot. ` + `Sessions: ${describeSessionChoices(sessions)}.`, ); } @@ -138,6 +156,7 @@ export class HunkDaemonState { sessionId: session.sessionId, title: session.title, sourceLabel: session.sourceLabel, + cwd: session.cwd, repoRoot: session.repoRoot, inputKind: session.inputKind, selectedFile, @@ -258,7 +277,7 @@ export class HunkDaemonState { sendComment(input: CommentToolInput) { return this.sendCommand( - { sessionId: input.sessionId, repoRoot: input.repoRoot }, + { sessionId: input.sessionId, sessionPath: input.sessionPath, repoRoot: input.repoRoot }, "comment", input, "Timed out waiting for the Hunk session to apply the comment.", @@ -267,7 +286,7 @@ export class HunkDaemonState { sendNavigateToHunk(input: NavigateToHunkToolInput) { return this.sendCommand( - { sessionId: input.sessionId, repoRoot: input.repoRoot }, + { sessionId: input.sessionId, sessionPath: input.sessionPath, repoRoot: input.repoRoot }, "navigate_to_hunk", input, "Timed out waiting for the Hunk session to navigate to the requested hunk.", @@ -276,7 +295,7 @@ export class HunkDaemonState { sendReloadSession(input: ReloadSessionToolInput) { return this.sendCommand( - { sessionId: input.sessionId, repoRoot: input.repoRoot }, + { sessionId: input.sessionId, sessionPath: input.sessionPath, repoRoot: input.repoRoot }, "reload_session", input, "Timed out waiting for the Hunk session to reload the requested contents.", @@ -286,7 +305,7 @@ export class HunkDaemonState { sendRemoveComment(input: RemoveCommentToolInput) { return this.sendCommand( - { sessionId: input.sessionId, repoRoot: input.repoRoot }, + { sessionId: input.sessionId, sessionPath: input.sessionPath, repoRoot: input.repoRoot }, "remove_comment", input, "Timed out waiting for the Hunk session to remove the requested comment.", @@ -295,7 +314,7 @@ export class HunkDaemonState { sendClearComments(input: ClearCommentsToolInput) { return this.sendCommand( - { sessionId: input.sessionId, repoRoot: input.repoRoot }, + { sessionId: input.sessionId, sessionPath: input.sessionPath, repoRoot: input.repoRoot }, "clear_comments", input, "Timed out waiting for the Hunk session to clear the requested comments.", diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4c9beb1..a2909fa 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -116,6 +116,7 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request) result: await state.sendReloadSession({ ...input.selector, nextInput: input.nextInput, + sourcePath: input.sourcePath, }), }; break; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 5f617c0..01f13cf 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -4,6 +4,7 @@ export type DiffSide = "old" | "new"; export interface SessionTargetInput { sessionId?: string; + sessionPath?: string; repoRoot?: string; } @@ -86,6 +87,7 @@ export interface NavigateToHunkToolInput extends SessionTargetInput { export interface ReloadSessionToolInput extends SessionTargetInput { nextInput: CliInput; + sourcePath?: string; } export interface LiveComment extends AgentAnnotation { @@ -157,6 +159,7 @@ export interface SelectedSessionContext { sessionId: string; title: string; sourceLabel: string; + cwd?: string; repoRoot?: string; inputKind: CliInput["kind"]; selectedFile: SessionFileSummary | null; diff --git a/src/session/commands.ts b/src/session/commands.ts index e83bebd..ac4c8c3 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -164,6 +164,7 @@ class HttpHunkDaemonCliClient implements HunkDaemonCliClient { action: "reload", selector: input.selector, nextInput: input.nextInput, + sourcePath: input.sourcePath, }) ).result; } @@ -258,6 +259,11 @@ function sessionMatchesSelector(session: ListedSession, selector?: SessionSelect return session.sessionId === selector.sessionId; } + const sessionPath = selector?.sessionPath; + if (sessionPath) { + return session.cwd === sessionPath; + } + if (selector.repoRoot) { return session.repoRoot === selector.repoRoot; } @@ -346,6 +352,10 @@ function formatSelector(selector: SessionSelectorInput) { return `session ${selector.sessionId}`; } + if (selector.sessionPath) { + return `session path ${selector.sessionPath}`; + } + if (selector.repoRoot) { return `repo ${selector.repoRoot}`; } @@ -425,7 +435,8 @@ function formatListOutput(sessions: ListedSession[]) { const terminal = resolveSessionTerminal(session); return [ `${session.sessionId} ${session.title}`, - ` repo: ${session.repoRoot ?? session.cwd}`, + ` path: ${session.cwd}`, + ` repo: ${session.repoRoot ?? "-"}`, ...formatTerminalLines(terminal, { headerLabel: " terminal", locationLabel: " location", @@ -445,7 +456,8 @@ function formatSessionOutput(session: ListedSession) { `Session: ${session.sessionId}`, `Title: ${session.title}`, `Source: ${session.sourceLabel}`, - `Repo: ${session.repoRoot ?? session.cwd}`, + `Path: ${session.cwd}`, + `Repo: ${session.repoRoot ?? "-"}`, `Input: ${session.inputKind}`, `Launched: ${session.launchedAt}`, ...formatTerminalLines(terminal, { @@ -477,6 +489,7 @@ function formatContextOutput(context: SelectedSessionContext) { return [ `Session: ${context.sessionId}`, `Title: ${context.title}`, + `Path: ${context.cwd ?? "-"}`, `Repo: ${context.repoRoot ?? "-"}`, `File: ${selectedFile}`, `Hunk: ${context.selectedHunk ? hunkNumber : "-"}`, @@ -534,14 +547,11 @@ function formatClearCommentsOutput(selector: SessionSelectorInput, result: Clear return `Cleared ${result.removedCount} live comments from ${scope}. Remaining comments: ${result.remainingCommentCount}.\n`; } -function normalizeRepoRoot(selector: SessionSelectorInput) { - if (!selector.repoRoot) { - return selector; - } - +function normalizeSessionSelector(selector: SessionSelectorInput) { return { ...selector, - repoRoot: resolve(selector.repoRoot), + sessionPath: selector.sessionPath ? resolve(selector.sessionPath) : undefined, + repoRoot: selector.repoRoot ? resolve(selector.repoRoot) : undefined, }; } @@ -581,7 +591,7 @@ export async function runSessionCommand(input: SessionCommandInput) { return renderOutput(input.output, { sessions: [] }, () => formatListOutput([])); } - const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null; + const normalizedSelector = "selector" in input ? normalizeSessionSelector(input.selector) : null; await ensureRequiredAction( REQUIRED_ACTION_BY_COMMAND[input.action], normalizedSelector ?? undefined, diff --git a/src/session/protocol.ts b/src/session/protocol.ts index 1394c08..73ac2ae 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -62,6 +62,7 @@ export type SessionDaemonRequest = action: "reload"; selector: SessionReloadCommandInput["selector"]; nextInput: SessionReloadCommandInput["nextInput"]; + sourcePath?: string; } | { action: "comment-add"; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index f8c3935..4e5f3f0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -94,7 +94,7 @@ function AppShell({ onQuit?: () => void; onReloadSession: ( nextInput: CliInput, - options?: { resetShell?: boolean }, + options?: { resetShell?: boolean; sourcePath?: string }, ) => Promise; }) { const FILES_MIN_WIDTH = 22; @@ -1033,10 +1033,14 @@ export function App({ const [shellVersion, setShellVersion] = useState(0); const reloadSession = useCallback( - async (nextInput: CliInput, options?: { resetShell?: boolean }) => { + async (nextInput: CliInput, options?: { resetShell?: boolean; sourcePath?: string }) => { const runtimeInput = resolveRuntimeCliInput(nextInput); - const configuredInput = resolveConfiguredCliInput(runtimeInput).input; - const nextBootstrap = await loadAppBootstrap(configuredInput); + const configuredInput = resolveConfiguredCliInput(runtimeInput, { + cwd: options?.sourcePath, + }).input; + const nextBootstrap = await loadAppBootstrap(configuredInput, { + cwd: options?.sourcePath, + }); const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); let sessionId = "local-session"; diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts index 6d3d69c..e802dc3 100644 --- a/src/ui/hooks/useHunkSessionBridge.ts +++ b/src/ui/hooks/useHunkSessionBridge.ts @@ -33,7 +33,7 @@ export function useHunkSessionBridge({ openAgentNotes: () => void; reloadSession: ( nextInput: CliInput, - options?: { resetShell?: boolean }, + options?: { resetShell?: boolean; sourcePath?: string }, ) => Promise; selectedFile: DiffFile | undefined; selectedHunkIndex: number; @@ -137,7 +137,7 @@ export function useHunkSessionBridge({ const reloadIncomingSession = useCallback( async (message: Extract) => - reloadSession(message.input.nextInput), + reloadSession(message.input.nextInput, { sourcePath: message.input.sourcePath }), [reloadSession], ); diff --git a/test/cli.test.ts b/test/cli.test.ts index df1e6fe..4b51dea 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -192,7 +192,7 @@ describe("parseCli", () => { }); }); - test("parses session get by repo", async () => { + test("parses session get by repo alias", async () => { const parsed = await parseCli(["bun", "hunk", "session", "get", "--repo", "."]); expect(parsed).toMatchObject({ @@ -257,6 +257,35 @@ describe("parseCli", () => { }); }); + test("parses split session reload with a separate session path and source directory", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "reload", + "--session-path", + "/tmp/live-window", + "--source", + "/tmp/source-repo", + "--json", + "--", + "diff", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "reload", + selector: { sessionPath: "/tmp/live-window" }, + sourcePath: "/tmp/source-repo", + nextInput: { + kind: "git", + staged: false, + options: {}, + }, + output: "json", + }); + }); + test("rejects session reload without a nested command separator", async () => { await expect( parseCli(["bun", "hunk", "session", "reload", "session-1", "show", "HEAD~1"]), diff --git a/test/loaders.test.ts b/test/loaders.test.ts index 79f7ef4..8675154 100644 --- a/test/loaders.test.ts +++ b/test/loaders.test.ts @@ -103,6 +103,39 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1); }); + test("loads git changes and relative agent context from an explicit cwd override", async () => { + const dir = createTempRepo("hunk-git-cwd-"); + const nested = join(dir, "nested"); + writeFileSync(join(dir, "example.ts"), "export const value = 1;\n"); + git(dir, "add", "example.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "example.ts"), "export const value = 2;\n"); + mkdirSync(nested, { recursive: true }); + writeFileSync( + join(nested, "agent.json"), + JSON.stringify({ + files: [{ path: "example.ts", annotations: [{ newRange: [1, 1], summary: "updated" }] }], + }), + ); + + const bootstrap = await loadAppBootstrap( + { + kind: "git", + staged: false, + options: { + mode: "auto", + agentContext: "agent.json", + }, + }, + { cwd: nested }, + ); + + expect(bootstrap.changeset.sourceLabel).toBe(dir); + expect(bootstrap.changeset.files[0]?.path).toBe("example.ts"); + expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1); + }); + test("loads git working tree changes from a temporary repo", async () => { const dir = createTempRepo("hunk-git-"); diff --git a/test/mcp-daemon.test.ts b/test/mcp-daemon.test.ts index 788426e..852ab73 100644 --- a/test/mcp-daemon.test.ts +++ b/test/mcp-daemon.test.ts @@ -99,25 +99,50 @@ function createLiveComment( } describe("Hunk MCP daemon state", () => { - test("resolves one target session by session id, repo root, or sole-session fallback", () => { + test("resolves one target session by session id, session path, repo root, or sole-session fallback", () => { const one = [createListedSession()]; const two = [ createListedSession(), createListedSession({ sessionId: "session-2", + cwd: "/other-session", + repoRoot: "/repo", snapshot: { ...createSnapshot(), updatedAt: "2026-03-22T00:00:01.000Z" }, }), ]; expect(resolveSessionTarget(one, {}).sessionId).toBe("session-1"); + expect(resolveSessionTarget(one, { sessionPath: "/repo" }).sessionId).toBe("session-1"); expect(resolveSessionTarget(one, { repoRoot: "/repo" }).sessionId).toBe("session-1"); expect(resolveSessionTarget(two, { sessionId: "session-2" }).sessionId).toBe("session-2"); - expect(() => resolveSessionTarget(two, {})).toThrow("specify sessionId or repoRoot"); + expect(() => resolveSessionTarget(two, {})).toThrow( + "specify sessionId, sessionPath, or repoRoot", + ); expect(() => resolveSessionTarget(two, { repoRoot: "/repo" })).toThrow( "specify sessionId instead", ); }); + test("keeps session-path matching tied to the live session cwd", () => { + const sessions = [ + createListedSession({ + sessionId: "session-f", + cwd: "/live-session", + repoRoot: "/source-f", + }), + createListedSession({ + sessionId: "session-a", + cwd: "/other-session", + repoRoot: "/source-a", + }), + ]; + + expect(resolveSessionTarget(sessions, { sessionPath: "/live-session" }).sessionId).toBe( + "session-f", + ); + expect(resolveSessionTarget(sessions, { repoRoot: "/source-a" }).sessionId).toBe("session-a"); + }); + test("exposes the selected session context from snapshot state", () => { const state = new HunkDaemonState(); const socket = { diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 927ecd7..7c61c61 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer } from "node:net"; +import { HunkDaemonState } from "../src/mcp/daemonState"; import { serveHunkMcpServer } from "../src/mcp/server"; const originalHost = process.env.HUNK_MCP_HOST; @@ -300,4 +301,65 @@ describe("Hunk session daemon server", () => { server.stop(true); } }); + + test("forwards reload sourcePath through the session API", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const original = HunkDaemonState.prototype.sendReloadSession; + HunkDaemonState.prototype.sendReloadSession = function (input) { + expect(input).toMatchObject({ + sessionPath: "/tmp/live-session", + sourcePath: "/tmp/source-repo", + nextInput: { + kind: "git", + staged: false, + options: {}, + }, + }); + + return Promise.resolve({ + sessionId: "session-1", + inputKind: "git", + title: "source-repo working tree", + sourceLabel: "/tmp/source-repo", + fileCount: 0, + selectedHunkIndex: 0, + }); + }; + + const server = serveHunkMcpServer(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + action: "reload", + selector: { sessionPath: "/tmp/live-session" }, + sourcePath: "/tmp/source-repo", + nextInput: { + kind: "git", + staged: false, + options: {}, + }, + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + result: { + sessionId: "session-1", + inputKind: "git", + sourceLabel: "/tmp/source-repo", + }, + }); + } finally { + HunkDaemonState.prototype.sendReloadSession = original; + server.stop(true); + } + }); }); diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index 0467488..c27b103 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; import type { SessionCommandInput, SessionSelectorInput } from "../src/core/types"; import { runSessionCommand, @@ -254,6 +255,59 @@ describe("session command compatibility checks", () => { }); }); + test("passes a separate source path through reload commands", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + reloadSession: async (input) => { + expect(input.selector).toEqual({ sessionPath: "/live-session" }); + expect(input.sourcePath).toBe("/source-repo"); + expect(input.nextInput).toEqual({ + kind: "git", + staged: false, + options: {}, + }); + + return { + sessionId: "session-1", + inputKind: "git", + title: "source-repo working tree", + sourceLabel: "/source-repo", + fileCount: 1, + selectedFilePath: "README.md", + selectedHunkIndex: 0, + }; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "reload", + selector: { sessionPath: "/live-session" }, + sourcePath: "/source-repo", + nextInput: { + kind: "git", + staged: false, + options: {}, + }, + output: "json", + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toEqual({ + result: { + sessionId: "session-1", + inputKind: "git", + title: "source-repo working tree", + sourceLabel: "/source-repo", + fileCount: 1, + selectedFilePath: "README.md", + selectedHunkIndex: 0, + }, + }); + }); + test("does not restart when the daemon already exposes the needed session action", async () => { const restartCalls: string[] = []; @@ -291,6 +345,50 @@ describe("session command compatibility checks", () => { expect(JSON.parse(output)).toEqual({ comments: [] }); expect(restartCalls).toEqual([]); }); + + test("normalizes session-path selectors for reload commands before calling the daemon client", async () => { + const expectedPath = resolve("."); + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + reloadSession: async (input) => { + const selector = input.selector; + expect(selector).toEqual({ + sessionPath: expectedPath, + }); + return { + sessionId: "session-1", + inputKind: "git", + title: "repo working tree", + sourceLabel: "/repo", + fileCount: 1, + selectedFilePath: "README.md", + selectedHunkIndex: 0, + }; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "reload", + selector: { sessionPath: "." }, + nextInput: { + kind: "git", + staged: false, + options: {}, + }, + output: "json", + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toMatchObject({ + result: { + sessionId: "session-1", + }, + }); + }); }); describe("session list includes terminal metadata", () => {