From 4237d506d0dfe2ae5ff68f68c777100ebe170d73 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sat, 7 Jun 2025 01:22:15 +0300 Subject: [PATCH] Fix deletion of committed files excluded in .gitignore, show branch info (#7) * Fix deletion of committed files excluded in .gitignore, show branch info * Add clickable branch links and support for remote branch/PR checkout - Branch names in UI are now clickable and link to GitHub - Added --remote-branch option to checkout remote branches - Added --pr option to checkout specific PRs by number - Fixed shadow repo sync to preserve git-tracked files regardless of gitignore - Updated documentation with shadow repo sync principles * Save work before checking out remote branch * Checkpoint * Checkpoint * Checkpoint --- CLAUDE.md | 11 +++ public/app.js | 92 ++++++++++++++++++++ public/index.html | 60 +++++++++++++ src/cli.ts | 11 ++- src/container.ts | 88 ++++++++++++++++--- src/git/shadow-repository.ts | 160 ++++++++++++++++++++++++++++++++--- src/index.ts | 101 +++++++++++++++++++--- src/types.ts | 2 + src/web-server.ts | 120 +++++++++++++++++++++++++- 9 files changed, 606 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db0ba0e..6a556e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,17 @@ This is the Claude Code Sandbox project - a CLI tool that runs Claude Code insta - Each session creates a new branch (`claude/[timestamp]`) - Real-time commit monitoring with interactive review +### Shadow Repository Sync Principles + +The shadow repository maintains a real-time sync with the container's workspace using the following principles: + +1. **Git-tracked files take precedence**: Any file that is committed to the git repository will be synced to the shadow repo, regardless of whether it matches patterns in `.gitignore` +2. **Gitignore patterns apply to untracked files**: Files that are not committed to git but match `.gitignore` patterns will be excluded from sync +3. **Built-in exclusions**: Certain directories (`.git`, `node_modules`, `__pycache__`, etc.) are always excluded for performance and safety +4. **Rsync rule order**: Include rules for git-tracked files are processed before exclude rules, ensuring committed files are always preserved + +This ensures that important data files (like corpora, model files, etc.) that are committed to the repository are never accidentally deleted during sync operations, even if they match common gitignore patterns like `*.zip` or `*.tar.gz`. + ## Configuration The tool looks for `claude-sandbox.config.json` in the working directory. Key options: diff --git a/public/app.js b/public/app.js index 39ef4d0..a01b5bf 100644 --- a/public/app.js +++ b/public/app.js @@ -429,6 +429,9 @@ function initSocket() { if (term) { term.focus(); } + + // Fetch git info for this container + fetchGitInfo(); }); socket.on("output", (data) => { @@ -639,6 +642,89 @@ function copySelection() { } } +// Git info functions +async function fetchGitInfo() { + try { + // Use container ID if available to get branch from shadow repo + const url = containerId + ? `/api/git/info?containerId=${containerId}` + : "/api/git/info"; + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + updateGitInfo(data); + } else { + console.error("Failed to fetch git info:", response.statusText); + } + } catch (error) { + console.error("Error fetching git info:", error); + } +} + +function updateGitInfo(data) { + const gitInfoElement = document.getElementById("git-info"); + const branchNameElement = document.getElementById("branch-name"); + const prInfoElement = document.getElementById("pr-info"); + + if (data.currentBranch) { + // Clear existing content + branchNameElement.innerHTML = ""; + + if (data.branchUrl) { + // Create clickable branch link + const branchLink = document.createElement("a"); + branchLink.href = data.branchUrl; + branchLink.target = "_blank"; + branchLink.textContent = data.currentBranch; + branchLink.style.color = "inherit"; + branchLink.style.textDecoration = "none"; + branchLink.title = `View ${data.currentBranch} branch on GitHub`; + branchLink.addEventListener("mouseenter", () => { + branchLink.style.textDecoration = "underline"; + }); + branchLink.addEventListener("mouseleave", () => { + branchLink.style.textDecoration = "none"; + }); + branchNameElement.appendChild(branchLink); + } else { + // Fallback to plain text + branchNameElement.textContent = data.currentBranch; + } + + gitInfoElement.style.display = "inline-block"; + } + + // Clear existing PR info + prInfoElement.innerHTML = ""; + + if (data.prs && data.prs.length > 0) { + data.prs.forEach((pr) => { + const prBadge = document.createElement("a"); + prBadge.className = "pr-badge"; + prBadge.href = pr.url; + prBadge.target = "_blank"; + prBadge.title = pr.title; + + // Set badge class based on state + if (pr.isDraft) { + prBadge.classList.add("draft"); + prBadge.textContent = `Draft PR #${pr.number}`; + } else if (pr.state === "OPEN") { + prBadge.classList.add("open"); + prBadge.textContent = `PR #${pr.number}`; + } else if (pr.state === "CLOSED") { + prBadge.classList.add("closed"); + prBadge.textContent = `Closed PR #${pr.number}`; + } else if (pr.state === "MERGED") { + prBadge.classList.add("merged"); + prBadge.textContent = `Merged PR #${pr.number}`; + } + + prInfoElement.appendChild(prBadge); + }); + } +} + // Initialize everything when DOM is ready document.addEventListener("DOMContentLoaded", () => { // Store original page title @@ -647,6 +733,12 @@ document.addEventListener("DOMContentLoaded", () => { initTerminal(); initSocket(); + // Fetch git info on load + fetchGitInfo(); + + // Refresh git info periodically + setInterval(fetchGitInfo, 30000); // Every 30 seconds + // Initialize audio on first user interaction (browser requirement) document.addEventListener( "click", diff --git a/public/index.html b/public/index.html index 2480869..dc9f41e 100644 --- a/public/index.html +++ b/public/index.html @@ -417,6 +417,47 @@ margin-bottom: 1rem; font-size: 1.1rem; } + + /* Git info styles */ + .git-info { + color: #7d8590; + font-size: 0.875rem; + } + + .pr-badge { + display: inline-block; + padding: 2px 6px; + font-size: 12px; + font-weight: 500; + line-height: 1; + border-radius: 3px; + text-decoration: none; + margin-left: 4px; + } + + .pr-badge.open { + background-color: #2ea043; + color: white; + } + + .pr-badge.draft { + background-color: #6e7681; + color: white; + } + + .pr-badge.closed { + background-color: #8250df; + color: white; + } + + .pr-badge.merged { + background-color: #8250df; + color: white; + } + + .pr-badge:hover { + opacity: 0.8; + } @@ -444,6 +485,25 @@ Claude Code Sandbox
+ Connecting...
diff --git a/src/cli.ts b/src/cli.ts index 20c87ef..b308e41 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -80,7 +80,7 @@ program ) .option("-n, --name ", "Container name prefix") .option("--no-push", "Disable automatic branch pushing") - .option("--no-pr", "Disable automatic PR creation") + .option("--no-create-pr", "Disable automatic PR creation") .option( "--include-untracked", "Include untracked files when copying to container", @@ -89,6 +89,11 @@ program "-b, --branch ", "Switch to specific branch on container start (creates if doesn't exist)", ) + .option( + "--remote-branch ", + "Checkout a remote branch (e.g., origin/feature-branch)", + ) + .option("--pr ", "Checkout a specific PR by number") .option( "--shell ", "Start with 'claude' or 'bash' shell", @@ -100,9 +105,11 @@ program const config = await loadConfig(options.config); config.containerPrefix = options.name || config.containerPrefix; config.autoPush = options.push !== false; - config.autoCreatePR = options.pr !== false; + config.autoCreatePR = options.createPr !== false; config.includeUntracked = options.includeUntracked || false; config.targetBranch = options.branch; + config.remoteBranch = options.remoteBranch; + config.prNumber = options.pr; if (options.shell) { config.defaultShell = options.shell.toLowerCase(); } diff --git a/src/container.ts b/src/container.ts index b8fa04c..447b17b 100644 --- a/src/container.ts +++ b/src/container.ts @@ -50,7 +50,12 @@ export class ContainerManager { console.log(chalk.green("✓ Container ready")); // Set up git branch and startup script - await this.setupGitAndStartupScript(container, containerConfig.branchName); + await this.setupGitAndStartupScript( + container, + containerConfig.branchName, + containerConfig.prFetchRef, + containerConfig.remoteFetchRef, + ); // Run setup commands await this.runSetupCommands(container); @@ -497,6 +502,18 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ const { execSync } = require("child_process"); const fs = require("fs"); + // Helper function to get tar flags safely + const getTarFlags = () => { + try { + // Test if --no-xattrs is supported by checking tar help + execSync("tar --help 2>&1 | grep -q no-xattrs", { stdio: "pipe" }); + return "--no-xattrs"; + } catch { + // --no-xattrs not supported, use standard tar + return ""; + } + }; + try { // Get list of git-tracked files (including uncommitted changes) const trackedFiles = execSync("git ls-files", { @@ -575,8 +592,9 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ const gitTarFile = `/tmp/claude-sandbox-git-${Date.now()}.tar`; // Exclude macOS resource fork files and .DS_Store when creating git archive // Also strip extended attributes to prevent macOS xattr issues in Docker + const tarFlags = getTarFlags(); execSync( - `tar -cf "${gitTarFile}" --exclude="._*" --exclude=".DS_Store" --no-xattrs .git`, + `tar -cf "${gitTarFile}" --exclude="._*" --exclude=".DS_Store" ${tarFlags} .git`, { cwd: workDir, stdio: "pipe", @@ -615,6 +633,18 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ const path = require("path"); const { execSync } = require("child_process"); + // Helper function to get tar flags safely + const getTarFlags = () => { + try { + // Test if --no-xattrs is supported by checking tar help + execSync("tar --help 2>&1 | grep -q no-xattrs", { stdio: "pipe" }); + return "--no-xattrs"; + } catch { + // --no-xattrs not supported, use standard tar + return ""; + } + }; + try { // First, try to get credentials from macOS Keychain if on Mac if (process.platform === "darwin") { @@ -754,9 +784,13 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ console.log(chalk.blue("• Copying .claude directory...")); const tarFile = `/tmp/claude-dir-${Date.now()}.tar`; - execSync(`tar -cf "${tarFile}" --no-xattrs -C "${os.homedir()}" .claude`, { - stdio: "pipe", - }); + const tarFlags = getTarFlags(); + execSync( + `tar -cf "${tarFile}" ${tarFlags} -C "${os.homedir()}" .claude`, + { + stdio: "pipe", + }, + ); const stream = fs.createReadStream(tarFile); await container.putArchive(stream, { @@ -869,6 +903,8 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ private async setupGitAndStartupScript( container: any, branchName: string, + prFetchRef?: string, + remoteFetchRef?: string, ): Promise { console.log(chalk.blue("• Setting up git branch and startup script...")); @@ -916,13 +952,37 @@ exec /bin/bash`; git config --global url."https://\${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:" echo "✓ Configured git to use GitHub token" fi && - # Try to checkout existing branch first, then create new if it doesn't exist - if git show-ref --verify --quiet refs/heads/"${branchName}"; then - git checkout "${branchName}" && - echo "✓ Switched to existing branch: ${branchName}" + # Handle different branch setup scenarios + if [ -n "${prFetchRef || ""}" ]; then + echo "• Fetching PR branch..." && + git fetch origin ${prFetchRef} && + if git show-ref --verify --quiet refs/heads/"${branchName}"; then + git checkout "${branchName}" && + echo "✓ Switched to existing PR branch: ${branchName}" + else + git checkout "${branchName}" && + echo "✓ Checked out PR branch: ${branchName}" + fi + elif [ -n "${remoteFetchRef || ""}" ]; then + echo "• Fetching remote branch..." && + git fetch origin && + if git show-ref --verify --quiet refs/heads/"${branchName}"; then + git checkout "${branchName}" && + git pull origin "${branchName}" && + echo "✓ Switched to existing remote branch: ${branchName}" + else + git checkout -b "${branchName}" "${remoteFetchRef}" && + echo "✓ Created local branch from remote: ${branchName}" + fi else - git checkout -b "${branchName}" && - echo "✓ Created new branch: ${branchName}" + # Regular branch creation + if git show-ref --verify --quiet refs/heads/"${branchName}"; then + git checkout "${branchName}" && + echo "✓ Switched to existing branch: ${branchName}" + else + git checkout -b "${branchName}" && + echo "✓ Created new branch: ${branchName}" + fi fi && cat > /home/claude/start-session.sh << 'EOF' ${startupScript} @@ -947,7 +1007,11 @@ EOF setupStream.on("end", () => { if ( (output.includes("✓ Created new branch") || - output.includes("✓ Switched to existing branch")) && + output.includes("✓ Switched to existing branch") || + output.includes("✓ Switched to existing remote branch") || + output.includes("✓ Switched to existing PR branch") || + output.includes("✓ Checked out PR branch") || + output.includes("✓ Created local branch from remote")) && output.includes("✓ Startup script created") ) { resolve(); diff --git a/src/git/shadow-repository.ts b/src/git/shadow-repository.ts index 13102f5..d472131 100644 --- a/src/git/shadow-repository.ts +++ b/src/git/shadow-repository.ts @@ -182,9 +182,9 @@ export class ShadowRepository { } } - private async prepareGitignoreExcludes(): Promise { + private async prepareRsyncRules(): Promise { try { - // Start with built-in excludes + // Start with built-in excludes that should never be synced const excludes: string[] = [ ".git", ".git/**", @@ -202,6 +202,25 @@ export class ShadowRepository { "Thumbs.db", ]; + // Get list of git-tracked files to ensure they're always included + let trackedFiles: string[] = []; + try { + const { stdout } = await execAsync("git ls-files", { + cwd: this.options.originalRepo, + }); + trackedFiles = stdout + .trim() + .split("\n") + .filter((f) => f.trim()); + console.log( + chalk.gray(` Found ${trackedFiles.length} git-tracked files`), + ); + } catch (error) { + console.log( + chalk.yellow(" Warning: Could not get git-tracked files:", error), + ); + } + // Check for .gitignore in original repo const gitignorePath = path.join(this.options.originalRepo, ".gitignore"); if (await fs.pathExists(gitignorePath)) { @@ -238,29 +257,144 @@ export class ShadowRepository { } } - // Write excludes to file - await fs.writeFile(this.rsyncExcludeFile, excludes.join("\n")); + // Create include patterns for all git-tracked files + // This ensures git-tracked files are synced even if they match gitignore patterns + const includes: string[] = []; + for (const file of trackedFiles) { + includes.push(`+ ${file}`); + // Also include parent directories + const parts = file.split("/"); + for (let i = 1; i < parts.length; i++) { + const dir = parts.slice(0, i).join("/"); + includes.push(`+ ${dir}/`); + } + } + + // Remove duplicates from includes + const uniqueIncludes = [...new Set(includes)]; + + // Write the rsync rules file: includes first, then excludes + // Rsync processes rules in order, so includes must come before excludes + const allRules = [...uniqueIncludes, ...excludes.map((e) => `- ${e}`)]; + await fs.writeFile(this.rsyncExcludeFile, allRules.join("\n")); + console.log( chalk.gray( - ` Created rsync exclude file with ${excludes.length} patterns`, + ` Created rsync rules file with ${uniqueIncludes.length} includes and ${excludes.length} excludes`, ), ); } catch (error) { console.log( - chalk.yellow(" Warning: Could not prepare gitignore excludes:", error), + chalk.yellow(" Warning: Could not prepare rsync rules:", error), ); // Create a basic exclude file with just the essentials const basicExcludes = [ - ".git", - "node_modules", - ".next", - "__pycache__", - ".venv", + "- .git", + "- node_modules", + "- .next", + "- __pycache__", + "- .venv", ]; await fs.writeFile(this.rsyncExcludeFile, basicExcludes.join("\n")); } } + async resetToContainerBranch(containerId: string): Promise { + console.log( + chalk.blue("🔄 Resetting shadow repo to match container branch..."), + ); + + try { + // Ensure shadow repo is initialized first + if (!this.initialized) { + await this.initialize(); + } + + // Get the current branch from the container + const { stdout: containerBranch } = await execAsync( + `docker exec ${containerId} git -C /workspace rev-parse --abbrev-ref HEAD`, + ); + const targetBranch = containerBranch.trim(); + console.log(chalk.blue(` Container is on branch: ${targetBranch}`)); + + // Get the current branch in shadow repo (if it has one) + let currentShadowBranch = ""; + try { + const { stdout: shadowBranch } = await execAsync( + "git rev-parse --abbrev-ref HEAD", + { cwd: this.shadowPath }, + ); + currentShadowBranch = shadowBranch.trim(); + console.log(chalk.blue(` Shadow repo is on: ${currentShadowBranch}`)); + } catch (error) { + console.log(chalk.blue(` Shadow repo has no HEAD yet`)); + } + + if (targetBranch !== currentShadowBranch) { + console.log( + chalk.blue(` Resetting shadow repo to match container...`), + ); + + // Fetch all branches from the original repo + try { + await execAsync("git fetch origin", { cwd: this.shadowPath }); + } catch (error) { + console.warn(chalk.yellow("Warning: Failed to fetch from origin")); + } + + // Check if the target branch exists remotely and create/checkout accordingly + try { + // Try to checkout the branch if it exists remotely and reset to match it + await execAsync( + `git checkout -B ${targetBranch} origin/${targetBranch}`, + { cwd: this.shadowPath }, + ); + console.log( + chalk.green( + `✓ Shadow repo reset to remote branch: ${targetBranch}`, + ), + ); + } catch (error) { + try { + // If that fails, try to checkout locally existing branch + await execAsync(`git checkout ${targetBranch}`, { + cwd: this.shadowPath, + }); + console.log( + chalk.green( + `✓ Shadow repo switched to local branch: ${targetBranch}`, + ), + ); + } catch (localError) { + // If that fails too, create a new branch + await execAsync(`git checkout -b ${targetBranch}`, { + cwd: this.shadowPath, + }); + console.log( + chalk.green(`✓ Shadow repo created new branch: ${targetBranch}`), + ); + } + } + + // Mark that we need to resync after branch reset + console.log( + chalk.blue(`✓ Branch reset complete - files will be synced next`), + ); + } else { + console.log( + chalk.gray( + ` Shadow repo already on correct branch: ${targetBranch}`, + ), + ); + } + } catch (error) { + console.warn( + chalk.yellow("⚠ Failed to reset shadow repo branch:"), + error, + ); + } + } + async syncFromContainer( containerId: string, containerPath: string = "/workspace", @@ -271,8 +405,8 @@ export class ShadowRepository { console.log(chalk.blue("🔄 Syncing files from container...")); - // Prepare gitignore excludes - await this.prepareGitignoreExcludes(); + // Prepare rsync rules + await this.prepareRsyncRules(); // First, ensure files in container are owned by claude user try { diff --git a/src/index.ts b/src/index.ts index d93e6a3..517f821 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,17 +38,90 @@ export class ClaudeSandbox { const currentBranch = await this.git.branchLocal(); console.log(chalk.blue(`Current branch: ${currentBranch.current}`)); - // Use target branch from config or generate one - const branchName = - this.config.targetBranch || - (() => { - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .split("T")[0]; - return `claude/${timestamp}-${Date.now()}`; - })(); - console.log(chalk.blue(`Will create branch in container: ${branchName}`)); + // Determine target branch based on config options (but don't checkout in host repo) + let branchName = ""; + let prFetchRef = ""; + let remoteFetchRef = ""; + + if (this.config.prNumber) { + // Get PR branch name from GitHub but don't checkout locally + console.log(chalk.blue(`Getting PR #${this.config.prNumber} info...`)); + try { + const { execSync } = require("child_process"); + + // Get PR info to find the actual branch name + const prInfo = execSync( + `gh pr view ${this.config.prNumber} --json headRefName`, + { + encoding: "utf-8", + cwd: process.cwd(), + }, + ); + const prData = JSON.parse(prInfo); + branchName = prData.headRefName; + prFetchRef = `pull/${this.config.prNumber}/head:${branchName}`; + + console.log( + chalk.blue( + `PR #${this.config.prNumber} uses branch: ${branchName}`, + ), + ); + console.log( + chalk.blue(`Will setup container with PR branch: ${branchName}`), + ); + } catch (error) { + console.error( + chalk.red(`✗ Failed to get PR #${this.config.prNumber} info:`), + error, + ); + throw error; + } + } else if (this.config.remoteBranch) { + // Parse remote branch but don't checkout locally + console.log( + chalk.blue( + `Will setup container with remote branch: ${this.config.remoteBranch}`, + ), + ); + try { + // Parse remote/branch format + const parts = this.config.remoteBranch.split("/"); + if (parts.length < 2) { + throw new Error( + 'Remote branch must be in format "remote/branch" (e.g., "origin/feature-branch")', + ); + } + + const remote = parts[0]; + const branch = parts.slice(1).join("/"); + + console.log(chalk.blue(`Remote: ${remote}, Branch: ${branch}`)); + branchName = branch; + remoteFetchRef = `${remote}/${branch}`; + } catch (error) { + console.error( + chalk.red( + `✗ Failed to parse remote branch ${this.config.remoteBranch}:`, + ), + error, + ); + throw error; + } + } else { + // Use target branch from config or generate one + branchName = + this.config.targetBranch || + (() => { + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .split("T")[0]; + return `claude/${timestamp}-${Date.now()}`; + })(); + console.log( + chalk.blue(`Will create branch in container: ${branchName}`), + ); + } // Discover credentials (optional - don't fail if not found) const credentials = await this.credentialManager.discover(); @@ -57,6 +130,8 @@ export class ClaudeSandbox { const containerConfig = await this.prepareContainer( branchName, credentials, + prFetchRef, + remoteFetchRef, ); // Start container @@ -110,6 +185,8 @@ export class ClaudeSandbox { private async prepareContainer( branchName: string, credentials: any, + prFetchRef?: string, + remoteFetchRef?: string, ): Promise { const workDir = process.cwd(); const repoName = path.basename(workDir); @@ -120,6 +197,8 @@ export class ClaudeSandbox { workDir, repoName, dockerImage: this.config.dockerImage || "claude-sandbox:latest", + prFetchRef, + remoteFetchRef, }; } diff --git a/src/types.ts b/src/types.ts index cd6a4e8..2eb5f8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export interface SandboxConfig { bashTimeout?: number; includeUntracked?: boolean; targetBranch?: string; + remoteBranch?: string; + prNumber?: string; } export interface Credentials { diff --git a/src/web-server.ts b/src/web-server.ts index 4354148..2bcc1a7 100644 --- a/src/web-server.ts +++ b/src/web-server.ts @@ -68,6 +68,91 @@ export class WebUIServer { res.status(500).json({ error: "Failed to list containers" }); } }); + + // Git info endpoint - get current branch and PRs + this.app.get("/api/git/info", async (req, res) => { + try { + const containerId = req.query.containerId as string; + let currentBranch = "loading..."; + let workingDir = this.originalRepo || process.cwd(); + + // If containerId is provided, try to get branch from shadow repo + if (containerId && this.shadowRepos.has(containerId)) { + const shadowRepo = this.shadowRepos.get(containerId)!; + const shadowPath = shadowRepo.getPath(); + if (shadowPath) { + try { + const branchResult = await execAsync( + "git rev-parse --abbrev-ref HEAD", + { + cwd: shadowPath, + }, + ); + currentBranch = branchResult.stdout.trim(); + // Use original repo for PR lookup (PRs are created against the main repo) + } catch (error) { + console.warn("Could not get branch from shadow repo:", error); + } + } + } else { + // Fallback to original repo + const branchResult = await execAsync( + "git rev-parse --abbrev-ref HEAD", + { + cwd: workingDir, + }, + ); + currentBranch = branchResult.stdout.trim(); + } + + // Get repository remote URL for branch links + let repoUrl = ""; + try { + const remoteResult = await execAsync("git remote get-url origin", { + cwd: this.originalRepo || process.cwd(), + }); + const remoteUrl = remoteResult.stdout.trim(); + + // Convert SSH URLs to HTTPS for web links + if (remoteUrl.startsWith("git@github.com:")) { + repoUrl = remoteUrl + .replace("git@github.com:", "https://github.com/") + .replace(".git", ""); + } else if (remoteUrl.startsWith("https://")) { + repoUrl = remoteUrl.replace(".git", ""); + } + } catch (error) { + console.warn("Could not get repository URL:", error); + } + + // Get PR info using GitHub CLI (always use original repo) + let prs = []; + try { + const prResult = await execAsync( + `gh pr list --head "${currentBranch}" --json number,title,state,url,isDraft,mergeable`, + { + cwd: this.originalRepo || process.cwd(), + }, + ); + prs = JSON.parse(prResult.stdout || "[]"); + } catch (error) { + // GitHub CLI might not be installed or not authenticated + console.warn("Could not fetch PR info:", error); + } + + const branchUrl = repoUrl ? `${repoUrl}/tree/${currentBranch}` : ""; + + res.json({ + currentBranch, + branchUrl, + repoUrl, + prs, + }); + } catch (error) { + console.error("Failed to get git info:", error); + res.status(500).json({ error: "Failed to get git info" }); + } + }); } private setupSocketHandlers(): void { @@ -376,6 +461,7 @@ export class WebUIServer { try { // Initialize shadow repo if not exists + let isNewShadowRepo = false; if (!this.shadowRepos.has(containerId)) { const shadowRepo = new ShadowRepository({ originalRepo: this.originalRepo || process.cwd(), @@ -383,12 +469,42 @@ export class WebUIServer { sessionId: containerId.substring(0, 12), }); this.shadowRepos.set(containerId, shadowRepo); + isNewShadowRepo = true; + + // Reset shadow repo to match container's branch (important for PR/remote branch scenarios) + await shadowRepo.resetToContainerBranch(containerId); } // Sync files from container (inotify already told us there are changes) const shadowRepo = this.shadowRepos.get(containerId)!; await shadowRepo.syncFromContainer(containerId); + // If this is a new shadow repo, establish a clean baseline after the first sync + if (isNewShadowRepo) { + console.log( + chalk.blue("🔄 Establishing clean baseline for new shadow repo..."), + ); + const shadowPath = shadowRepo.getPath(); + + try { + // Stage all synced files and create a baseline commit + await execAsync("git add -A", { cwd: shadowPath }); + await execAsync( + 'git commit -m "Establish baseline from container content" --allow-empty', + { cwd: shadowPath }, + ); + console.log(chalk.green("✓ Clean baseline established")); + + // Now do one more sync to see if there are any actual changes + await shadowRepo.syncFromContainer(containerId); + } catch (baselineError) { + console.warn( + chalk.yellow("Warning: Could not establish baseline"), + baselineError, + ); + } + } + // Check if shadow repo actually has git initialized const shadowPath = shadowRepo.getPath(); const gitPath = path.join(shadowPath, ".git"); @@ -404,7 +520,9 @@ export class WebUIServer { // Get changes summary and diff data const changes = await shadowRepo.getChanges(); - console.log(chalk.gray(`[MONITOR] Shadow repo changes:`, changes)); + console.log( + chalk.gray(`[MONITOR] Shadow repo changes: ${changes.summary}`), + ); let diffData = null;