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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-01-28 10:29:52 -08:00
committed by GitHub
parent 5474469cab
commit 48f53eed16
8 changed files with 194 additions and 14 deletions
+6 -1
View File
@@ -347,6 +347,7 @@ const App: React.FC = () => {
const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false);
const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
const [sharingEnabled, setSharingEnabled] = useState(true);
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
const viewerRef = useRef<ViewerHandle>(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}
/>
</div>
</main>
+11
View File
@@ -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'}
</span>
)}
{repoInfo && (
<>
<span className="text-muted-foreground/40 hidden md:inline">|</span>
<span className="text-xs text-muted-foreground/60 font-mono hidden md:inline truncate max-w-[200px]" title={repoInfo.display}>
{repoInfo.display}
</span>
</>
)}
</div>
<div className="flex items-center gap-1 md:gap-2">
+5 -1
View File
@@ -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)
+2 -1
View File
@@ -11,7 +11,8 @@
"./remote": "./remote.ts",
"./browser": "./browser.ts",
"./storage": "./storage.ts",
"./git": "./git.ts"
"./git": "./git.ts",
"./repo": "./repo.ts"
},
"files": [
"*.ts"
+114
View File
@@ -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<string | undefined> {
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<RepoInfo | null> {
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;
}
+5
View File
@@ -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,
});
}
+20 -1
View File
@@ -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<ViewerHandle, ViewerProps>(({
globalAttachments = [],
onAddGlobalAttachment,
onRemoveGlobalAttachment,
repoInfo,
}, ref) => {
const [copied, setCopied] = useState(false);
const [showGlobalCommentInput, setShowGlobalCommentInput] = useState(false);
@@ -628,7 +630,24 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
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 && (
<div className="absolute top-3 left-3 md:top-4 md:left-5 flex items-center gap-1.5 text-[9px] text-muted-foreground/50 font-mono">
<span className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]" title={repoInfo.display}>
{repoInfo.display}
</span>
{repoInfo.branch && (
<span className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden" title={repoInfo.branch}>
<svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
<span className="truncate">{repoInfo.branch}</span>
</span>
)}
</div>
)}
{/* Header buttons - top right */}
<div className="absolute top-3 right-3 md:top-5 md:right-5 flex items-start gap-2">
{/* Attachments button */}
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
+31 -10
View File
@@ -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 ""