mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
Merge remote-tracking branch 'origin/main' into feat/decision-control-pr7
# Conflicts: # packages/core/guide-viewer-manifest.ts
This commit is contained in:
@@ -44,6 +44,16 @@ const SKILL_MD_PATH = join(
|
||||
"SKILL.md",
|
||||
);
|
||||
|
||||
const ANNOTATE_SKILL_MD_PATH = join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"..",
|
||||
"skills",
|
||||
"core",
|
||||
"plannotator-annotate",
|
||||
"SKILL.md",
|
||||
);
|
||||
|
||||
// CLI sources that parse flags. index.ts strips flags via
|
||||
// args.indexOf/includes; the shared parsers use case/=== comparisons.
|
||||
const PARSER_SOURCES = [
|
||||
@@ -112,6 +122,7 @@ for (const m of indexSource.matchAll(/args\[0\] === "([a-z][a-z0-9-]*)"/g)) {
|
||||
// --- The skill's documented surface ---
|
||||
|
||||
const skillDoc = readFileSync(SKILL_MD_PATH, "utf-8");
|
||||
const annotateSkillDoc = readFileSync(ANNOTATE_SKILL_MD_PATH, "utf-8");
|
||||
|
||||
const documentedSubcommands = new Set<string>();
|
||||
for (const fence of skillDoc.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
|
||||
@@ -126,6 +137,16 @@ for (const m of skillDoc.matchAll(/(?<![\w-])--[a-z][a-z0-9-]*/g)) {
|
||||
}
|
||||
|
||||
describe("plannotator knowledge skill freshness", () => {
|
||||
test("file approval guidance enables the annotate gate", () => {
|
||||
expect(skillDoc).toContain("plannotator annotate <file> --gate --json");
|
||||
expect(annotateSkillDoc).toContain(
|
||||
"plannotator annotate <path-or-url> --gate --json",
|
||||
);
|
||||
expect(annotateSkillDoc).toContain(
|
||||
"`--json` only changes the output format and does not enable approval by itself",
|
||||
);
|
||||
});
|
||||
|
||||
test("extractors actually extracted (a parsing regression must not pass vacuously)", () => {
|
||||
// If the fence or flag regexes stop matching, the forward assertions
|
||||
// below would pass on empty sets. Pin known-present anchors instead of
|
||||
|
||||
@@ -8,19 +8,27 @@ disable-model-invocation: true
|
||||
|
||||
Use this skill when the user wants to annotate a document in Plannotator instead of reviewing it inline in chat.
|
||||
|
||||
Run:
|
||||
Run for ordinary annotation/feedback:
|
||||
|
||||
```bash
|
||||
plannotator annotate <path-or-url>
|
||||
```
|
||||
|
||||
Run when the user asks to review, approve, accept, or gate a generated plan/spec/document:
|
||||
|
||||
```bash
|
||||
plannotator annotate <path-or-url> --gate --json
|
||||
```
|
||||
|
||||
Plain `annotate` has no **Approve** button; it only supports feedback or closing the session. Never promise an approval action unless `--gate` is present. `--json` only changes the output format and does not enable approval by itself.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Launch the command with Bash.
|
||||
2. Wait for the browser review to finish.
|
||||
3. If annotations are returned, address them directly.
|
||||
4. If the session closes without feedback, say so briefly and continue.
|
||||
5. An approval may still carry notes — a `"decision": "approved"` result with a
|
||||
5. In a `--gate --json` session, an approval may still carry notes — a `"decision": "approved"` result with a
|
||||
`"feedback"` field. Read those notes and carry them into subsequent work, but
|
||||
do not revise the document over them: they are guidance, not a change request.
|
||||
6. If the command reports that the arguments could not be resolved to a file,
|
||||
|
||||
@@ -14,6 +14,7 @@ This skill is the knowledge layer. The `plannotator-review`, `plannotator-annota
|
||||
| The user wants | Run |
|
||||
| --- | --- |
|
||||
| Review a plan you produced | Nothing. Plan review opens automatically on plan exit via hooks. Never run bare `plannotator` yourself. |
|
||||
| Review and explicitly approve a plan/spec saved as a file | `plannotator annotate <file> --gate --json` |
|
||||
| Review current code changes | `plannotator review` |
|
||||
| Review a GitHub PR or GitLab MR | `plannotator review <PR_URL>` |
|
||||
| Annotate a markdown, text, config, or HTML file | `plannotator annotate <file>` |
|
||||
@@ -58,6 +59,8 @@ plannotator annotate <target> [--markdown] [--no-jina] [--app | --static] [--ren
|
||||
|
||||
Opens one document, page, or app in the annotation UI and returns the human's annotations on stdout.
|
||||
|
||||
Plain `annotate` is feedback-only: it shows **Close** but no **Approve** button. When the user asks to review, approve, accept, or gate a generated plan/spec/document saved as a file, always add `--gate --json`. Do not tell the user they can approve a plain `annotate` session. If the plan is being handed off through the host agent's native plan flow, do not launch `annotate`; let the plan-exit hook open the approval UI automatically.
|
||||
|
||||
Targets:
|
||||
|
||||
- Markdown and text files: `.md`, `.mdx`, `.txt`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DefaultDiffType = 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all';
|
||||
export type DefaultDiffType = 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all';
|
||||
export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong';
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
import type { GuideViewerAssets } from "./guide-format";
|
||||
|
||||
export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
|
||||
js: "viewer.AnU5z-dd.js",
|
||||
js: "viewer.blC1stpK.js",
|
||||
css: "viewer.ByOFxnTX.css",
|
||||
jsIntegrity: "sha384-iOm8MrJ1lGtn93UvbQr02EvCOHwQqXcDd84+5pUwfCSoYy146a+tLaemgZjNql8c",
|
||||
jsIntegrity: "sha384-q9ezrElUnwFDt71ATj8EDsVTJ0L3yA6NnhGlxMFq2MaWrjLVYCiW97zd+kjaziXM",
|
||||
cssIntegrity: "sha384-CbCmVLMKK2kuIPAIp7dRn/7IN9SfDOou7bzRmdtcyRqHNy/dqv+V83zzYY0gVBhE",
|
||||
langs: {
|
||||
"astro": "chunks/astro.BykyiR6i.js",
|
||||
|
||||
@@ -1370,7 +1370,7 @@ const ReviewApp: React.FC = () => {
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
if (lastColon !== -1) {
|
||||
const sub = rest.slice(lastColon + 1);
|
||||
if (['since-base', 'uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) {
|
||||
if (['since-base', 'local-vs-remote', 'uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) {
|
||||
return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub };
|
||||
}
|
||||
}
|
||||
@@ -4161,6 +4161,7 @@ const ReviewApp: React.FC = () => {
|
||||
: undefined;
|
||||
|
||||
const compactActionBusy = isSendingFeedback || isApproving || isExiting || isPlatformActioning;
|
||||
const showsLocalVsRemoteEmptyState = activeDiffBase === 'local-vs-remote';
|
||||
const compactReviewActions: CompactReviewAction[] = !isCompactTouchLayout
|
||||
? []
|
||||
: !origin
|
||||
@@ -5058,6 +5059,7 @@ const ReviewApp: React.FC = () => {
|
||||
<h3 className="text-sm font-medium text-foreground">No changes</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeDiffBase === 'since-base' && `No changes since ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''} — committed, uncommitted, or untracked.`}
|
||||
{showsLocalVsRemoteEmptyState && `Your local branch matches its remote-tracking branch${activeWorktreePath ? ' in this worktree' : ''}.`}
|
||||
{activeDiffBase.startsWith('commit:') && 'This commit has no changes.'}
|
||||
{activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`}
|
||||
{activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface DiffTypePickerProps {
|
||||
*/
|
||||
const OPTION_HINTS: Record<string, string> = {
|
||||
'since-base': "Everything since your branch split from the base — committed, uncommitted, and untracked. What a PR would show if you committed it all and pushed.",
|
||||
'local-vs-remote': "Your local branch and working tree compared with its remote-tracking branch — committed, uncommitted, and untracked differences from the last fetch.",
|
||||
uncommitted: "All your local changes — anything you haven't committed yet.",
|
||||
staged: "Only what you've run `git add` on.",
|
||||
unstaged: "What `git diff` shows with no arguments.",
|
||||
|
||||
@@ -21,7 +21,7 @@ interface UseGitAddReturn {
|
||||
stageError: string | null;
|
||||
}
|
||||
|
||||
const STAGEABLE_DIFF_TYPES = new Set(['since-base', 'uncommitted', 'unstaged', 'workspace-current', 'workspace-unstaged']);
|
||||
const STAGEABLE_DIFF_TYPES = new Set(['since-base', 'local-vs-remote', 'uncommitted', 'unstaged', 'workspace-current', 'workspace-unstaged']);
|
||||
|
||||
export function useGitAdd({ activeDiffBase, onFileViewed, sidecarStaged }: UseGitAddOptions): UseGitAddReturn {
|
||||
// Session intent per path: true = staged this session, false = unstaged
|
||||
|
||||
@@ -65,6 +65,7 @@ function describeDiff(ctx: FeedbackDiffContext): string {
|
||||
}
|
||||
switch (mode) {
|
||||
case "uncommitted": label = "Uncommitted changes"; break;
|
||||
case "local-vs-remote": label = "Local vs remote branch (committed + uncommitted + untracked)"; break;
|
||||
case "staged": label = "Staged changes"; break;
|
||||
case "unstaged": label = "Unstaged changes"; break;
|
||||
case "last-commit": label = "Last commit"; break;
|
||||
|
||||
@@ -47,6 +47,17 @@ describe('initializeReviewSetup', () => {
|
||||
expect(store.get('defaultDiffType')).toBe('uncommitted');
|
||||
});
|
||||
|
||||
test('an unseen reviewer inherits a local-vs-remote default', () => {
|
||||
installMemoryBackend({
|
||||
'plannotator-default-diff-type': 'local-vs-remote',
|
||||
});
|
||||
const store = makeStore();
|
||||
|
||||
expect(initializeReviewSetup(store)).toBe(true);
|
||||
expect(store.get('reviewPanelView')).toBe('tree');
|
||||
expect(store.get('defaultDiffType')).toBe('local-vs-remote');
|
||||
});
|
||||
|
||||
test('an explicit persisted view survives a session that never tripped the seen gate', () => {
|
||||
// Non-git / workspace / PR / no-since-base sessions never reach the
|
||||
// initializer, so a reviewer can persist a view from Settings while
|
||||
|
||||
@@ -275,6 +275,14 @@ describe("buildAgentReviewUserMessage — Ask AI scenario coverage", () => {
|
||||
});
|
||||
|
||||
describe("getLocalDiffInstruction", () => {
|
||||
test("describes local-vs-remote as an upstream-to-working-tree comparison", () => {
|
||||
const instruction = getLocalDiffInstruction("local-vs-remote");
|
||||
|
||||
expect(instruction?.target).toContain("remote-tracking branch");
|
||||
expect(instruction?.inspect).toContain("@{upstream}");
|
||||
expect(instruction?.inspect).toContain("untracked files");
|
||||
});
|
||||
|
||||
test("returns null for non-local diff types", () => {
|
||||
expect(getLocalDiffInstruction("p4-default")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -208,6 +208,11 @@ export function getLocalDiffInstruction(
|
||||
}
|
||||
|
||||
switch (effectiveDiffType) {
|
||||
case "local-vs-remote":
|
||||
return {
|
||||
target: "the local branch and working tree compared with its configured remote-tracking branch, including committed, uncommitted, and untracked differences",
|
||||
inspect: "Resolve the current upstream with `git rev-parse --abbrev-ref --symbolic-full-name @{upstream}`, then run `git diff <upstream>` (no right-hand ref) and inspect untracked files from `git status --porcelain` separately.",
|
||||
};
|
||||
case "since-base": {
|
||||
const base = defaultBranch || "main";
|
||||
return {
|
||||
|
||||
@@ -26,12 +26,21 @@ import {
|
||||
getServerConfig,
|
||||
resolveGuideShareUrl,
|
||||
resolveSharingEnabled,
|
||||
resolveDefaultDiffType,
|
||||
DEFAULT_GUIDE_SHARE_URL,
|
||||
__setConfigLockTimingsForTest,
|
||||
__setConfigSaveMergeWindowHookForTest,
|
||||
} from "./config";
|
||||
import type { PlannotatorConfig } from "./config";
|
||||
|
||||
describe("resolveDefaultDiffType", () => {
|
||||
test("accepts local-vs-remote as a persisted review default", () => {
|
||||
expect(resolveDefaultDiffType({
|
||||
diffOptions: { defaultDiffType: "local-vs-remote" },
|
||||
})).toBe("local-vs-remote");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReviewAnalysisConfig", () => {
|
||||
test("accepts independent boolean analysis flags", () => {
|
||||
expect(parseReviewAnalysisConfig({ semanticDiff: false })).toEqual({ semanticDiff: false });
|
||||
|
||||
@@ -605,7 +605,7 @@ export function isAgentTerminalSide(
|
||||
export function resolveDefaultDiffType(cfg?: PlannotatorConfig): DefaultDiffType {
|
||||
const v = cfg?.diffOptions?.defaultDiffType as string | undefined;
|
||||
if (v === 'branch') return 'merge-base';
|
||||
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : 'since-base';
|
||||
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : 'since-base';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1282,6 +1282,42 @@ describe("review-core", () => {
|
||||
expect(result.patch).toContain("diff --git a/untracked.txt b/untracked.txt");
|
||||
});
|
||||
|
||||
test("local-vs-remote includes committed, dirty, and untracked changes since the tracked branch", async () => {
|
||||
const repoDir = initRepo();
|
||||
const remoteDir = makeTempDir("plannotator-review-core-upstream-");
|
||||
git(remoteDir, ["init", "--bare", "--initial-branch=main"]);
|
||||
git(repoDir, ["remote", "add", "origin", remoteDir]);
|
||||
git(repoDir, ["push", "--set-upstream", "origin", "main"]);
|
||||
|
||||
writeFileSync(join(repoDir, "committed.txt"), "committed\n", "utf-8");
|
||||
git(repoDir, ["add", "committed.txt"]);
|
||||
git(repoDir, ["commit", "-m", "local commit"]);
|
||||
writeFileSync(join(repoDir, "tracked.txt"), "dirty\n", "utf-8");
|
||||
writeFileSync(join(repoDir, "untracked.txt"), "new\n", "utf-8");
|
||||
|
||||
const runtime = makeRuntime(repoDir);
|
||||
const context = await getGitContext(runtime, repoDir);
|
||||
const result = await runGitDiff(runtime, "local-vs-remote", context.defaultBranch, repoDir);
|
||||
|
||||
// Intentional copy pins: these labels are the product terminology shown in the diff picker and header.
|
||||
expect(context.diffOptions).toContainEqual({
|
||||
id: "local-vs-remote",
|
||||
label: "Local vs remote branch",
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.label).toBe("main: Local vs origin/main");
|
||||
expect(result.patch).toContain("diff --git a/committed.txt b/committed.txt");
|
||||
expect(result.patch).toContain("diff --git a/tracked.txt b/tracked.txt");
|
||||
expect(result.patch).toContain("diff --git a/untracked.txt b/untracked.txt");
|
||||
});
|
||||
|
||||
test("git context hides local-vs-remote when the current branch has no upstream", async () => {
|
||||
const repoDir = initRepo();
|
||||
const context = await getGitContext(makeRuntime(repoDir), repoDir);
|
||||
|
||||
expect(context.diffOptions.map((option) => option.id)).not.toContain("local-vs-remote");
|
||||
});
|
||||
|
||||
test("since-base falls back to HEAD when the requested base cannot resolve", async () => {
|
||||
const repoDir = initRepo("trunk");
|
||||
const runtime = makeRuntime(repoDir);
|
||||
@@ -1649,6 +1685,7 @@ describe("review-core", () => {
|
||||
// which pointed git at a non-existent cwd and silently collapsed the diff mode.
|
||||
const subTypes = [
|
||||
"since-base",
|
||||
"local-vs-remote",
|
||||
"uncommitted",
|
||||
"staged",
|
||||
"unstaged",
|
||||
|
||||
@@ -27,6 +27,7 @@ const MAX_UNTRACKED_FINGERPRINT_CONTENT_BYTES = 1024 * 1024;
|
||||
|
||||
export type DiffType =
|
||||
| "since-base"
|
||||
| "local-vs-remote"
|
||||
| "uncommitted"
|
||||
| "staged"
|
||||
| "unstaged"
|
||||
@@ -658,6 +659,11 @@ export async function getGitContext(
|
||||
}
|
||||
}
|
||||
|
||||
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
|
||||
if (upstreamBranch) {
|
||||
diffOptions.push({ id: "local-vs-remote", label: "Local vs remote branch" });
|
||||
}
|
||||
|
||||
diffOptions.push(
|
||||
{ id: "uncommitted", label: "Uncommitted changes" },
|
||||
{ id: "staged", label: "Staged changes" },
|
||||
@@ -1309,6 +1315,20 @@ export async function getWorkingTreeDiffFromBase(
|
||||
return removeTrackedDeletions(trackedPatch, new Set(untracked.paths)) + untracked.diff;
|
||||
}
|
||||
|
||||
/** Resolve the remote-tracking branch configured for the current local branch. */
|
||||
export async function getCurrentUpstreamBranch(
|
||||
runtime: ReviewGitRuntime,
|
||||
cwd?: string,
|
||||
): Promise<string | null> {
|
||||
const result = await runtime.runGit(
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
||||
{ cwd },
|
||||
);
|
||||
if (result.exitCode !== 0) return null;
|
||||
const branch = result.stdout.trim();
|
||||
return branch && branch !== "@{upstream}" ? branch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact, applyable patch used to materialize immutable analysis snapshots.
|
||||
*
|
||||
@@ -1333,6 +1353,7 @@ export async function getGitSnapshotMaterializationPatch(
|
||||
}
|
||||
if (
|
||||
effectiveDiffType !== "since-base"
|
||||
&& effectiveDiffType !== "local-vs-remote"
|
||||
&& effectiveDiffType !== "uncommitted"
|
||||
&& effectiveDiffType !== "staged"
|
||||
&& effectiveDiffType !== "unstaged"
|
||||
@@ -1370,6 +1391,13 @@ export async function getGitSnapshotMaterializationPatch(
|
||||
return removeTrackedDeletions(tracked, new Set(files.paths)) + files.diff;
|
||||
}
|
||||
|
||||
if (effectiveDiffType === "local-vs-remote") {
|
||||
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
|
||||
if (!upstreamBranch) throw new Error("The current branch does not have a remote tracking branch.");
|
||||
const tracked = await binaryDiff([...common, "--end-of-options", upstreamBranch]);
|
||||
return removeTrackedDeletions(tracked, new Set(files.paths)) + files.diff;
|
||||
}
|
||||
|
||||
const mergeBaseResult = await runtime.runGit(
|
||||
["merge-base", "--end-of-options", defaultBranch, "HEAD"],
|
||||
{ cwd },
|
||||
@@ -1422,6 +1450,7 @@ function assertGitSuccess(
|
||||
// extract the pure parser to a browser-safe module.
|
||||
const WORKTREE_SUB_TYPES = new Set([
|
||||
"since-base",
|
||||
"local-vs-remote",
|
||||
"uncommitted",
|
||||
"staged",
|
||||
"unstaged",
|
||||
@@ -1559,6 +1588,16 @@ export async function runGitDiff(
|
||||
} else if (effectiveDiffType.startsWith("commit:")) {
|
||||
return { patch: "", label: `Error: ${diffType}`, error: "Invalid commit ref" };
|
||||
} else switch (effectiveDiffType) {
|
||||
case "local-vs-remote": {
|
||||
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
|
||||
if (!upstreamBranch) {
|
||||
throw new Error("The current branch does not have a remote tracking branch.");
|
||||
}
|
||||
patch = await getWorkingTreeDiffFromBase(runtime, upstreamBranch, cwd, options);
|
||||
label = `Local vs ${displayRef(upstreamBranch)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "since-base": {
|
||||
// The composite "GitHub view": merge-base(base, HEAD) vs the working
|
||||
// tree (note: no right-hand ref on the diff), plus untracked files.
|
||||
@@ -1946,6 +1985,15 @@ export async function getGitDiffFingerprint(
|
||||
appendUntrackedFingerprint(runtime, runReadOnlyGit, parts, cwd);
|
||||
|
||||
switch (effectiveDiffType) {
|
||||
case "local-vs-remote": {
|
||||
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
|
||||
if (!upstreamBranch) return null;
|
||||
const upstreamTip = await runReadOnlyGit(["rev-parse", "--end-of-options", upstreamBranch]);
|
||||
parts.push(upstreamBranch, upstreamTip.exitCode === 0 ? upstreamTip.stdout.trim() : "no-upstream");
|
||||
if (!(await hashDiffOutput(["--end-of-options", upstreamBranch]))) return null;
|
||||
if (!(await hashUntracked())) return null;
|
||||
break;
|
||||
}
|
||||
case "since-base": {
|
||||
// Content hash of the mb→worktree diff catches edits; headSha (always
|
||||
// in `parts`) catches commits that only re-partition the sections;
|
||||
@@ -2060,6 +2108,13 @@ export async function getFileContentsForDiff(
|
||||
}
|
||||
|
||||
switch (effectiveDiffType) {
|
||||
case "local-vs-remote": {
|
||||
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
|
||||
return {
|
||||
oldContent: upstreamBranch ? await gitShow(upstreamBranch, oldFilePath) : null,
|
||||
newContent: await readWorkingTree(filePath),
|
||||
};
|
||||
}
|
||||
case "since-base": {
|
||||
const mbResult = await runtime.runGit(["merge-base", "--end-of-options", defaultBranch, "HEAD"], { cwd });
|
||||
// Degrade to HEAD (matching runGitDiff), not defaultBranch — when the base
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type GitDiffOptions,
|
||||
type ReviewGitRuntime,
|
||||
detectRemoteDefaultBranch,
|
||||
getCurrentUpstreamBranch,
|
||||
getFileContentsForDiff as getGitFileContentsForDiff,
|
||||
getGitContext,
|
||||
getGitDiffFingerprint,
|
||||
@@ -167,7 +168,7 @@ export interface PreparedLocalReviewDiff {
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
const GIT_DIFF_TYPES = new Set(["since-base", "uncommitted", "staged", "unstaged", "last-commit", "branch", "merge-base", "all"]);
|
||||
const GIT_DIFF_TYPES = new Set(["since-base", "local-vs-remote", "uncommitted", "staged", "unstaged", "last-commit", "branch", "merge-base", "all"]);
|
||||
const JJ_DIFF_TYPES = new Set(["jj-current", "jj-last", "jj-line", "jj-evolog", "jj-all"]);
|
||||
|
||||
function selectNearestProvider(
|
||||
@@ -231,6 +232,7 @@ export function createGitProvider(runtime: ReviewGitRuntime): VcsProvider {
|
||||
const effectiveDiffType = parseWorktreeDiffType(diffType)?.subType ?? diffType;
|
||||
return (
|
||||
effectiveDiffType === "since-base" ||
|
||||
effectiveDiffType === "local-vs-remote" ||
|
||||
effectiveDiffType === "uncommitted" ||
|
||||
effectiveDiffType === "unstaged"
|
||||
);
|
||||
@@ -722,6 +724,7 @@ function supportsGitSnapshot(diffType: string): boolean {
|
||||
const effective = parseWorktreeDiffType(diffType)?.subType ?? diffType;
|
||||
return effective !== "all" && (
|
||||
effective === "since-base"
|
||||
|| effective === "local-vs-remote"
|
||||
|| effective === "uncommitted"
|
||||
|| effective === "staged"
|
||||
|| effective === "unstaged"
|
||||
@@ -778,6 +781,14 @@ async function materializeGitSnapshot(
|
||||
const mergeBase = await git(runtime, cwd, ["merge-base", "--", options.base, "HEAD"]);
|
||||
return createSyntheticSnapshot(runtime, cwd, mergeBase, [patch]);
|
||||
}
|
||||
if (diffType === "local-vs-remote") {
|
||||
const upstream = await getCurrentUpstreamBranch(runtime, cwd);
|
||||
if (!upstream) {
|
||||
throw new Error("The current branch does not have a remote tracking branch.");
|
||||
}
|
||||
const from = await resolveCommit(runtime, cwd, upstream);
|
||||
return createSyntheticSnapshot(runtime, cwd, from, [patch]);
|
||||
}
|
||||
const head = await resolveCommit(runtime, cwd, "HEAD");
|
||||
if (diffType === "uncommitted" || diffType === "staged") {
|
||||
return createSyntheticSnapshot(runtime, cwd, head, [patch]);
|
||||
@@ -882,4 +893,3 @@ async function materializeJjSnapshot(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
|
||||
// "All Changes" belongs to since-base (the flagship composite); uncommitted
|
||||
// reverts to its plain name so the two stay distinguishable side by side.
|
||||
{ value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
|
||||
{ value: 'local-vs-remote' as const, label: 'Local vs Remote Branch', description: "Your local branch and working tree compared with its last-fetched remote-tracking branch" },
|
||||
{ value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
|
||||
{ value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
|
||||
{ value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { resetStorageBackend, setStorageBackend } from '../utils/storage';
|
||||
import { SETTINGS } from './settings';
|
||||
import { ConfigStoreForTest } from './configStore';
|
||||
import { setReviewPanelView } from './reviewView';
|
||||
import { setReviewDefaultDiffType, setReviewPanelView } from './reviewView';
|
||||
|
||||
function installMemoryBackend(): Map<string, string> {
|
||||
const values = new Map<string, string>();
|
||||
@@ -74,4 +74,16 @@ describe('reviewPanelViewLastUsed setting', () => {
|
||||
// ...but the user's last-used view survived.
|
||||
expect(store.get('reviewPanelViewLastUsed')).toBe('tree');
|
||||
});
|
||||
|
||||
test('local-vs-remote persists as a Tree-compatible default', () => {
|
||||
const values = installMemoryBackend();
|
||||
const store = makeStore();
|
||||
setReviewPanelView('sections', undefined, store);
|
||||
|
||||
setReviewDefaultDiffType('local-vs-remote', store);
|
||||
|
||||
expect(store.get('defaultDiffType')).toBe('local-vs-remote');
|
||||
expect(store.get('reviewPanelView')).toBe('tree');
|
||||
expect(values.get('plannotator-default-diff-type')).toBe('local-vs-remote');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ export function getPersistedReviewPanelView(): 'sections' | 'tree' | undefined {
|
||||
|
||||
export type ReviewDefaultDiffType =
|
||||
| 'since-base'
|
||||
| 'local-vs-remote'
|
||||
| 'uncommitted'
|
||||
| 'unstaged'
|
||||
| 'staged'
|
||||
|
||||
@@ -288,18 +288,18 @@ export const SETTINGS = {
|
||||
},
|
||||
|
||||
defaultDiffType: {
|
||||
defaultValue: 'since-base' as 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
|
||||
defaultValue: 'since-base' as 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
|
||||
fromCookie: () => {
|
||||
const v = storage.getItem('plannotator-default-diff-type');
|
||||
if (v === 'branch') return 'merge-base' as const;
|
||||
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
||||
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
||||
},
|
||||
toCookie: (v: string) => storage.setItem('plannotator-default-diff-type', v),
|
||||
serverKey: 'diffOptions',
|
||||
fromServer: (sc: Record<string, unknown>) => {
|
||||
const v = (sc.diffOptions as Record<string, unknown> | undefined)?.defaultDiffType;
|
||||
if (v === 'branch') return 'merge-base' as const;
|
||||
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
||||
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
||||
},
|
||||
toServer: (v: string) => ({ diffOptions: { defaultDiffType: v } }),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user