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
12 changes: 10 additions & 2 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down Expand Up @@ -78,15 +83,18 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext {
}

/** Load the optional agent-context sidecar from a file path or stdin. */
export async function loadAgentContext(pathOrDash?: string): Promise<AgentContext | null> {
export async function loadAgentContext(
pathOrDash?: string,
{ cwd = process.cwd() }: AgentContextLoadOptions = {},
): Promise<AgentContext | null> {
if (!pathOrDash) {
return null;
}

const raw =
pathOrDash === "-"
? await new Response(Bun.stdin.stream()).text()
: await Bun.file(pathOrDash).text();
: await Bun.file(resolvePath(cwd, pathOrDash)).text();

const parsed = JSON.parse(raw) as Record<string, unknown>;

Expand Down
103 changes: 88 additions & 15 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> or --repo <path> as the target, not both.",
);
}

if (sessionId && sessionPath) {
throw new Error("Specify either <session-id> or --session-path <path>, not both.");
}

if (sessionId && repoRoot) {
throw new Error("Specify either <session-id> or --repo <path>, 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 <session-id> or --repo <path> (or --session-path <path>).",
);
}

/** Parse the overloaded `hunk diff` command. */
async function parseDiffCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
Expand Down Expand Up @@ -453,13 +500,13 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session get --repo <path>",
" hunk session context <session-id>",
" hunk session context --repo <path>",
" hunk session navigate <session-id> --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
" hunk session reload <session-id> -- diff [ref] [-- <pathspec...>]",
" hunk session reload <session-id> -- show [ref] [-- <pathspec...>]",
" hunk session comment add <session-id> --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
" hunk session comment list <session-id>",
" hunk session comment rm <session-id> <comment-id>",
" hunk session comment clear <session-id> --yes",
" hunk session navigate (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- diff [ref] [-- <pathspec...>]",
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- show [ref] [-- <pathspec...>]",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
" hunk session comment list (<session-id> | --repo <path>)",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) --yes",
].join("\n") + "\n",
};
}
Expand Down Expand Up @@ -598,15 +645,23 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.description("replace the contents of one live Hunk session")
.argument("[sessionId]")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--session-path <path>", "target a live session rooted at a different path")
.option("--source <path>", "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 {
Expand All @@ -618,6 +673,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" 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",
};
Expand All @@ -638,12 +694,19 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {

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,
};
}
Expand Down Expand Up @@ -829,12 +892,22 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.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;
Expand Down
68 changes: 46 additions & 22 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 =
Expand All @@ -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);
Expand All @@ -360,16 +368,20 @@ 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`
: input.range
? `${repoName} ${input.range}`
: `${repoName} working tree`;
const trackedChangeset = normalizePatchChangeset(
runGitText({ input, args: buildGitDiffArgs(input) }),
runGitText({ input, args: buildGitDiffArgs(input), cwd }),
title,
repoRoot,
agentContext,
Expand Down Expand Up @@ -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,
Expand All @@ -416,25 +432,30 @@ 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,
);
}

/** 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(
Expand All @@ -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<AppBootstrap> {
const agentContext = await loadAgentContext(input.options.agentContext);
export async function loadAppBootstrap(
input: CliInput,
{ cwd = process.cwd() }: LoadAppBootstrapOptions = {},
): Promise<AppBootstrap> {
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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export type SessionCommandOutput = "text" | "json";

export interface SessionSelectorInput {
sessionId?: string;
sessionPath?: string;
repoRoot?: string;
}

Expand Down Expand Up @@ -123,6 +124,7 @@ export interface SessionReloadCommandInput {
output: SessionCommandOutput;
selector: SessionSelectorInput;
nextInput: CliInput;
sourcePath?: string;
}

export interface SessionCommentAddCommandInput {
Expand Down
Loading
Loading