From 48f53eed16158e4de2fb6b4c9385a8af13e6d9f2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 28 Jan 2026 10:29:52 -0800 Subject: [PATCH] feat: display repo and branch info in plan document (#117) * feat: display repo and branch info in plan document Add subtle badge display in upper-left corner of plan document showing: - Repository name (org/repo from git remote, with fallbacks) - Current branch with git branch icon New modular repo.ts handles detection with fallback chain: 1. Parse org/repo from git remote origin 2. Fall back to git repo root directory name 3. Fall back to current working directory Closes #104 Co-Authored-By: Claude Opus 4.5 * test: add --no-git flag to sandbox script Allows testing repo info fallback behavior when not in a git repository. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- packages/editor/App.tsx | 7 +- packages/review-editor/App.tsx | 11 +++ packages/server/index.ts | 6 +- packages/server/package.json | 3 +- packages/server/repo.ts | 114 +++++++++++++++++++++++++ packages/server/review.ts | 5 ++ packages/ui/components/Viewer.tsx | 21 ++++- tests/manual/local/sandbox-opencode.sh | 41 ++++++--- 8 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 packages/server/repo.ts diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 17456b1b..9b64f815 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -347,6 +347,7 @@ const App: React.FC = () => { const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); const [sharingEnabled, setSharingEnabled] = useState(true); + const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); const viewerRef = useRef(null); // URL-based sharing @@ -404,12 +405,15 @@ const App: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: 'claude-code' | 'opencode'; sharingEnabled?: boolean }) => { + .then((data: { plan: string; origin?: 'claude-code' | 'opencode'; sharingEnabled?: boolean; repoInfo?: { display: string; branch?: string } }) => { setMarkdown(data.plan); setIsApiMode(true); if (data.sharingEnabled !== undefined) { setSharingEnabled(data.sharingEnabled); } + if (data.repoInfo) { + setRepoInfo(data.repoInfo); + } if (data.origin) { setOrigin(data.origin); // For Claude Code, check if user needs to configure permission mode @@ -814,6 +818,7 @@ const App: React.FC = () => { globalAttachments={globalAttachments} onAddGlobalAttachment={handleAddGlobalAttachment} onRemoveGlobalAttachment={handleRemoveGlobalAttachment} + repoInfo={repoInfo} /> diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 1099c88d..aceab782 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -150,6 +150,7 @@ const ReviewApp: React.FC = () => { const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false); const [showApproveWarning, setShowApproveWarning] = useState(false); const [sharingEnabled, setSharingEnabled] = useState(true); + const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); const identity = useMemo(() => getIdentity(), []); @@ -203,6 +204,7 @@ const ReviewApp: React.FC = () => { diffType?: string; gitContext?: GitContext; sharingEnabled?: boolean; + repoInfo?: { display: string; branch?: string }; }) => { const apiFiles = parseDiffToFiles(data.rawPatch); setDiffData({ @@ -219,6 +221,7 @@ const ReviewApp: React.FC = () => { if (data.diffType) setDiffType(data.diffType); if (data.gitContext) setGitContext(data.gitContext); if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); + if (data.repoInfo) setRepoInfo(data.repoInfo); }) .catch(() => { // Not in API mode - use demo content @@ -523,6 +526,14 @@ const ReviewApp: React.FC = () => { {origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} )} + {repoInfo && ( + <> + | + + {repoInfo.display} + + + )}
diff --git a/packages/server/index.ts b/packages/server/index.ts index 11733c7b..dd6da35f 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -25,6 +25,7 @@ import { saveAnnotations, saveFinalSnapshot, } from "./storage"; +import { getRepoInfo } from "./repo"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -99,6 +100,9 @@ export async function startPlannotatorServer( // Generate slug for potential saving (actual save happens on decision) const slug = generateSlug(plan); + // Detect repo info (cached for this session) + const repoInfo = await getRepoInfo(); + // Decision promise let resolveDecision: (result: { approved: boolean; @@ -130,7 +134,7 @@ export async function startPlannotatorServer( // API: Get plan content if (url.pathname === "/api/plan") { - return Response.json({ plan, origin, permissionMode, sharingEnabled }); + return Response.json({ plan, origin, permissionMode, sharingEnabled, repoInfo }); } // API: Serve images (local paths or temp uploads) diff --git a/packages/server/package.json b/packages/server/package.json index 1669f987..1abd3d45 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,7 +11,8 @@ "./remote": "./remote.ts", "./browser": "./browser.ts", "./storage": "./storage.ts", - "./git": "./git.ts" + "./git": "./git.ts", + "./repo": "./repo.ts" }, "files": [ "*.ts" diff --git a/packages/server/repo.ts b/packages/server/repo.ts new file mode 100644 index 00000000..88ec876a --- /dev/null +++ b/packages/server/repo.ts @@ -0,0 +1,114 @@ +/** + * Repository Info Detection + * + * Detects org/repo from git remote with fallback chain. + * Priority: org/repo from remote → repo name → directory name + */ + +import { $ } from "bun"; + +export interface RepoInfo { + /** Display string (e.g., "backnotprop/plannotator" or "my-project") */ + display: string; + /** Current git branch (if in a git repo) */ + branch?: string; +} + +/** + * Parse org/repo from a git remote URL + * + * Handles: + * - SSH: git@github.com:org/repo.git + * - HTTPS: https://github.com/org/repo.git + * - SSH with port: ssh://git@github.com:22/org/repo.git + */ +function parseRemoteUrl(url: string): string | null { + if (!url) return null; + + // SSH format: git@github.com:org/repo.git + const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + + // HTTPS format: https://github.com/org/repo.git + const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + + return null; +} + +/** + * Get directory name from path + */ +function getDirName(path: string): string | null { + if (!path) return null; + const trimmed = path.trim().replace(/\/+$/, ""); + const parts = trimmed.split("/"); + return parts[parts.length - 1] || null; +} + +/** + * Get current git branch + */ +async function getCurrentBranch(): Promise { + try { + const result = await $`git rev-parse --abbrev-ref HEAD`.quiet().nothrow(); + if (result.exitCode === 0) { + const branch = result.stdout.toString().trim(); + return branch && branch !== "HEAD" ? branch : undefined; + } + } catch { + // Not in a git repo + } + return undefined; +} + +/** + * Detect repository info with fallback chain + * + * 1. Try org/repo from git remote origin + * 2. Fall back to git repo root directory name + * 3. Fall back to current working directory name + */ +export async function getRepoInfo(): Promise { + let branch: string | undefined; + + // Try git remote URL first + try { + const result = await $`git remote get-url origin`.quiet().nothrow(); + if (result.exitCode === 0) { + const orgRepo = parseRemoteUrl(result.stdout.toString().trim()); + if (orgRepo) { + branch = await getCurrentBranch(); + return { display: orgRepo, branch }; + } + } + } catch { + // Git not available + } + + // Fallback: git repo root name + try { + const result = await $`git rev-parse --show-toplevel`.quiet().nothrow(); + if (result.exitCode === 0) { + const repoName = getDirName(result.stdout.toString()); + if (repoName) { + branch = await getCurrentBranch(); + return { display: repoName, branch }; + } + } + } catch { + // Not in a git repo + } + + // Final fallback: current directory (no branch - not a git repo) + try { + const dirName = getDirName(process.cwd()); + if (dirName) { + return { display: dirName }; + } + } catch { + // Shouldn't happen + } + + return null; +} diff --git a/packages/server/review.ts b/packages/server/review.ts index 5e0d2bf4..69fc1e05 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -13,6 +13,7 @@ import { mkdirSync } from "fs"; import { isRemoteSession, getServerPort } from "./remote"; import { openBrowser } from "./browser"; import { type DiffType, type GitContext, runGitDiff } from "./git"; +import { getRepoInfo } from "./repo"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -89,6 +90,9 @@ export async function startReviewServer( const isRemote = isRemoteSession(); const configuredPort = getServerPort(); + // Detect repo info (cached for this session) + const repoInfo = await getRepoInfo(); + // Decision promise let resolveDecision: (result: { feedback: string; @@ -123,6 +127,7 @@ export async function startReviewServer( diffType: currentDiffType, gitContext, sharingEnabled, + repoInfo, }); } diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index db142d1f..63bb8aab 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -48,6 +48,7 @@ interface ViewerProps { globalAttachments?: string[]; onAddGlobalAttachment?: (path: string) => void; onRemoveGlobalAttachment?: (path: string) => void; + repoInfo?: { display: string; branch?: string } | null; } export interface ViewerHandle { @@ -102,6 +103,7 @@ export const Viewer = forwardRef(({ globalAttachments = [], onAddGlobalAttachment, onRemoveGlobalAttachment, + repoInfo, }, ref) => { const [copied, setCopied] = useState(false); const [showGlobalCommentInput, setShowGlobalCommentInput] = useState(false); @@ -628,7 +630,24 @@ export const Viewer = forwardRef(({ ref={containerRef} className="w-full max-w-[832px] 2xl:max-w-5xl bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative" > - {/* Header buttons */} + {/* Repo info - top left */} + {repoInfo && ( +
+ + {repoInfo.display} + + {repoInfo.branch && ( + + + + + {repoInfo.branch} + + )} +
+ )} + + {/* Header buttons - top right */}
{/* Attachments button */} {onAddGlobalAttachment && onRemoveGlobalAttachment && ( diff --git a/tests/manual/local/sandbox-opencode.sh b/tests/manual/local/sandbox-opencode.sh index 4268ff82..6c9662d8 100755 --- a/tests/manual/local/sandbox-opencode.sh +++ b/tests/manual/local/sandbox-opencode.sh @@ -2,12 +2,13 @@ # Sandbox script for testing Plannotator OpenCode plugin locally # # Usage: -# ./sandbox-opencode.sh [--disable-sharing] [--keep] +# ./sandbox-opencode.sh [--disable-sharing] [--keep] [--no-git] # # Options: # --disable-sharing Create opencode.json with "share": "disabled" to test # the sharing disable feature without env var pollution # --keep Don't clean up sandbox on exit (for debugging) +# --no-git Don't initialize git repo (tests non-git fallback) # # What it does: # 1. Builds the plugin (ensures latest code) @@ -29,6 +30,7 @@ PLUGIN_DIR="$PROJECT_ROOT/apps/opencode-plugin" # Parse CLI flags DISABLE_SHARING=false KEEP_SANDBOX=false +NO_GIT=false for arg in "$@"; do case $arg in --disable-sharing) @@ -39,6 +41,10 @@ for arg in "$@"; do KEEP_SANDBOX=true shift ;; + --no-git) + NO_GIT=true + shift + ;; esac done @@ -70,11 +76,13 @@ cleanup() { } trap cleanup EXIT -# Initialize git repo +# Initialize git repo (unless --no-git) cd "$SANDBOX_DIR" -git init -q -git config user.email "test@example.com" -git config user.name "Test User" +if [ "$NO_GIT" = false ]; then + git init -q + git config user.email "test@example.com" + git config user.name "Test User" +fi # Create initial project structure mkdir -p src/{api,components,hooks,utils,types} @@ -708,8 +716,10 @@ cat > .opencode/package.json << 'EOF' } EOF -git add . -git commit -q -m "Initial commit: Task manager app" +if [ "$NO_GIT" = false ]; then + git add . + git commit -q -m "Initial commit: Task manager app" +fi # ============================================================================= # Make uncommitted changes (simulating a feature branch with multiple changes) @@ -1517,8 +1527,12 @@ describe('formatters', () => { EOF echo "" -echo "Git status (uncommitted changes for /plannotator-review):" -git diff --stat +if [ "$NO_GIT" = false ]; then + echo "Git status (uncommitted changes for /plannotator-review):" + git diff --stat +else + echo "Git: DISABLED (--no-git flag)" +fi echo "" # Set up local plugin via loader file @@ -1555,6 +1569,11 @@ fi echo "=== Sandbox Ready ===" echo "" echo "Directory: $SANDBOX_DIR" +if [ "$NO_GIT" = true ]; then + echo "Git: DISABLED (--no-git)" +else + echo "Git: enabled" +fi if [ "$DISABLE_SHARING" = true ]; then echo "Sharing: DISABLED (via opencode.json config)" else @@ -1563,7 +1582,9 @@ fi echo "" echo "To test:" echo " 1. Plan mode: Ask the agent to plan something" -echo " 2. Code review: Run /plannotator-review" +if [ "$NO_GIT" = false ]; then + echo " 2. Code review: Run /plannotator-review" +fi echo "" echo "Launching OpenCode..." echo ""