From 0703113f8d9361575c42e331a5af0e845194c1d7 Mon Sep 17 00:00:00 2001
From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
Date: Fri, 6 Jun 2025 22:31:11 +0200
Subject: [PATCH 1/6] Fix deletion of committed files excluded in .gitignore,
show branch info
---
CLAUDE.md | 11 ++++++
public/app.js | 67 ++++++++++++++++++++++++++++++++++++
public/index.html | 48 ++++++++++++++++++++++++++
src/container.ts | 30 ++++++++++++++--
src/git/shadow-repository.ts | 57 +++++++++++++++++++++++-------
src/web-server.ts | 55 +++++++++++++++++++++++++++++
6 files changed, 253 insertions(+), 15 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..d5d6c65 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,64 @@ 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) {
+ 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
@@ -646,6 +707,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(
diff --git a/public/index.html b/public/index.html
index 2480869..5b96f4f 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,13 @@
Claude Code Sandbox
+
+
+ loading...
+
+
Connecting...
diff --git a/src/container.ts b/src/container.ts
index b8fa04c..800f3cc 100644
--- a/src/container.ts
+++ b/src/container.ts
@@ -497,6 +497,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 +587,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 +628,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,7 +779,8 @@ 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`, {
+ const tarFlags = getTarFlags();
+ execSync(`tar -cf "${tarFile}" ${tarFlags} -C "${os.homedir()}" .claude`, {
stdio: "pipe",
});
diff --git a/src/git/shadow-repository.ts b/src/git/shadow-repository.ts
index 13102f5..4160f44 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,18 @@ 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,24 +250,43 @@ 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"));
}
@@ -271,8 +302,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/web-server.ts b/src/web-server.ts
index 4354148..ce6fa2c 100644
--- a/src/web-server.ts
+++ b/src/web-server.ts
@@ -68,6 +68,61 @@ 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 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);
+ }
+
+ res.json({
+ currentBranch,
+ prs,
+ });
+ } catch (error) {
+ console.error("Failed to get git info:", error);
+ res.status(500).json({ error: "Failed to get git info" });
+ }
+ });
}
private setupSocketHandlers(): void {
From cce9a186b919ff886995b4a58b4108e5e96f7cc2 Mon Sep 17 00:00:00 2001
From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
Date: Fri, 6 Jun 2025 23:07:22 +0200
Subject: [PATCH 2/6] 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
---
public/app.js | 25 +++++++++++++++-
src/cli.ts | 14 +++++++--
src/index.ts | 72 +++++++++++++++++++++++++++++++++++++++--------
src/types.ts | 2 ++
src/web-server.ts | 22 +++++++++++++++
5 files changed, 121 insertions(+), 14 deletions(-)
diff --git a/public/app.js b/public/app.js
index d5d6c65..240d2af 100644
--- a/public/app.js
+++ b/public/app.js
@@ -665,7 +665,30 @@ function updateGitInfo(data) {
const prInfoElement = document.getElementById("pr-info");
if (data.currentBranch) {
- branchNameElement.textContent = 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";
}
diff --git a/src/cli.ts b/src/cli.ts
index 20c87ef..59764a0 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,14 @@ 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 +108,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/index.ts b/src/index.ts
index d93e6a3..b0d8c05 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -38,17 +38,67 @@ 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
+ let branchName = "";
+
+ if (this.config.prNumber) {
+ // Checkout PR
+ console.log(chalk.blue(`Fetching PR #${this.config.prNumber}...`));
+ try {
+ branchName = `pr-${this.config.prNumber}`;
+
+ // Check if PR branch already exists locally
+ const branches = await this.git.branchLocal();
+ if (branches.all.includes(branchName)) {
+ // PR branch exists, just checkout
+ await this.git.checkout(branchName);
+ console.log(chalk.green(`✓ Switched to existing PR branch: ${branchName}`));
+ } else {
+ // Fetch and create new PR branch
+ await this.git.fetch("origin", `pull/${this.config.prNumber}/head:${branchName}`);
+ await this.git.checkout(branchName);
+ console.log(chalk.green(`✓ Checked out PR #${this.config.prNumber}`));
+ }
+ } catch (error) {
+ console.error(chalk.red(`✗ Failed to checkout PR #${this.config.prNumber}:`), error);
+ throw error;
+ }
+ } else if (this.config.remoteBranch) {
+ // Checkout remote branch
+ console.log(chalk.blue(`Checking out remote branch: ${this.config.remoteBranch}...`));
+ try {
+ await this.git.fetch("origin");
+ const localBranchName = this.config.remoteBranch.replace("origin/", "");
+
+ // Check if local branch already exists
+ const branches = await this.git.branchLocal();
+ if (branches.all.includes(localBranchName)) {
+ // Local branch exists, just checkout
+ await this.git.checkout(localBranchName);
+ console.log(chalk.green(`✓ Switched to existing branch: ${localBranchName}`));
+ } else {
+ // Create new local branch from remote
+ await this.git.checkoutBranch(localBranchName, this.config.remoteBranch);
+ console.log(chalk.green(`✓ Checked out remote branch: ${this.config.remoteBranch}`));
+ }
+ branchName = localBranchName;
+ } catch (error) {
+ console.error(chalk.red(`✗ Failed to checkout 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();
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 ce6fa2c..8c7f85c 100644
--- a/src/web-server.ts
+++ b/src/web-server.ts
@@ -99,6 +99,24 @@ export class WebUIServer {
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 {
@@ -114,8 +132,12 @@ export class WebUIServer {
console.warn("Could not fetch PR info:", error);
}
+ const branchUrl = repoUrl ? `${repoUrl}/tree/${currentBranch}` : "";
+
res.json({
currentBranch,
+ branchUrl,
+ repoUrl,
prs,
});
} catch (error) {
From 30b7b355255e4c4ccf926a19f1b0c64cef9fef1c Mon Sep 17 00:00:00 2001
From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
Date: Fri, 6 Jun 2025 23:34:34 +0200
Subject: [PATCH 3/6] Save work before checking out remote branch
---
src/index.ts | 87 +++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 65 insertions(+), 22 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index b0d8c05..c2a5c61 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -42,23 +42,44 @@ export class ClaudeSandbox {
let branchName = "";
if (this.config.prNumber) {
- // Checkout PR
+ // Checkout PR - get the actual branch name from GitHub
console.log(chalk.blue(`Fetching PR #${this.config.prNumber}...`));
try {
- branchName = `pr-${this.config.prNumber}`;
+ const { execSync } = require("child_process");
- // Check if PR branch already exists locally
+ // Check for uncommitted changes first
+ const status = await this.git.status();
+ if (!status.isClean()) {
+ console.log(chalk.yellow("Warning: You have uncommitted changes. Committing them first..."));
+ await this.git.add("./*");
+ await this.git.commit("Save work before checking out PR");
+ console.log(chalk.green("✓ Committed uncommitted changes"));
+ }
+
+ // 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);
+ const actualBranchName = prData.headRefName;
+
+ console.log(chalk.blue(`PR #${this.config.prNumber} uses branch: ${actualBranchName}`));
+
+ // Fetch the PR
+ await this.git.fetch('origin', `pull/${this.config.prNumber}/head:${actualBranchName}`);
+
+ // Check if branch exists locally
const branches = await this.git.branchLocal();
- if (branches.all.includes(branchName)) {
- // PR branch exists, just checkout
- await this.git.checkout(branchName);
- console.log(chalk.green(`✓ Switched to existing PR branch: ${branchName}`));
+ if (branches.all.includes(actualBranchName)) {
+ await this.git.checkout(actualBranchName);
+ console.log(chalk.green(`✓ Switched to existing branch: ${actualBranchName}`));
} else {
- // Fetch and create new PR branch
- await this.git.fetch("origin", `pull/${this.config.prNumber}/head:${branchName}`);
- await this.git.checkout(branchName);
- console.log(chalk.green(`✓ Checked out PR #${this.config.prNumber}`));
+ await this.git.checkout(actualBranchName);
+ console.log(chalk.green(`✓ Checked out PR #${this.config.prNumber} as branch: ${actualBranchName}`));
}
+
+ branchName = actualBranchName;
} catch (error) {
console.error(chalk.red(`✗ Failed to checkout PR #${this.config.prNumber}:`), error);
throw error;
@@ -67,21 +88,43 @@ export class ClaudeSandbox {
// Checkout remote branch
console.log(chalk.blue(`Checking out remote branch: ${this.config.remoteBranch}...`));
try {
- await this.git.fetch("origin");
- const localBranchName = this.config.remoteBranch.replace("origin/", "");
+ // Check for uncommitted changes first
+ const status = await this.git.status();
+ if (!status.isClean()) {
+ console.log(chalk.yellow("Warning: You have uncommitted changes. Committing them first..."));
+ await this.git.add("./*");
+ await this.git.commit("Save work before checking out remote branch");
+ console.log(chalk.green("✓ Committed uncommitted changes"));
+ }
- // Check if local branch already exists
+ // 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}`));
+
+ // Fetch from remote
+ await this.git.fetch(remote);
+
+ // Check if local branch exists
const branches = await this.git.branchLocal();
- if (branches.all.includes(localBranchName)) {
- // Local branch exists, just checkout
- await this.git.checkout(localBranchName);
- console.log(chalk.green(`✓ Switched to existing branch: ${localBranchName}`));
+ if (branches.all.includes(branch)) {
+ // Local branch exists, switch to it and pull
+ await this.git.checkout(branch);
+ await this.git.pull(remote, branch);
+ console.log(chalk.green(`✓ Switched to existing local branch: ${branch}`));
} else {
- // Create new local branch from remote
- await this.git.checkoutBranch(localBranchName, this.config.remoteBranch);
- console.log(chalk.green(`✓ Checked out remote branch: ${this.config.remoteBranch}`));
+ // Create new local branch tracking the remote
+ await this.git.checkoutBranch(branch, `${remote}/${branch}`);
+ console.log(chalk.green(`✓ Created and checked out new branch: ${branch} tracking ${remote}/${branch}`));
}
- branchName = localBranchName;
+
+ branchName = branch;
} catch (error) {
console.error(chalk.red(`✗ Failed to checkout remote branch ${this.config.remoteBranch}:`), error);
throw error;
From 0dd82034aec57167ad88a401a20333a7f18b6e75 Mon Sep 17 00:00:00 2001
From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
Date: Fri, 6 Jun 2025 23:55:14 +0200
Subject: [PATCH 4/6] Checkpoint
---
src/container.ts | 46 +++++++++++++++++++++++-----
src/index.ts | 79 ++++++++++++------------------------------------
2 files changed, 58 insertions(+), 67 deletions(-)
diff --git a/src/container.ts b/src/container.ts
index 800f3cc..23849b0 100644
--- a/src/container.ts
+++ b/src/container.ts
@@ -50,7 +50,7 @@ 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);
@@ -895,6 +895,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..."));
@@ -942,13 +944,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}
@@ -973,7 +999,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/index.ts b/src/index.ts
index c2a5c61..9d831e3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -38,65 +38,36 @@ export class ClaudeSandbox {
const currentBranch = await this.git.branchLocal();
console.log(chalk.blue(`Current branch: ${currentBranch.current}`));
- // Determine target branch based on config options
+ // Determine target branch based on config options (but don't checkout in host repo)
let branchName = "";
+ let prFetchRef = "";
+ let remoteFetchRef = "";
if (this.config.prNumber) {
- // Checkout PR - get the actual branch name from GitHub
- console.log(chalk.blue(`Fetching PR #${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");
- // Check for uncommitted changes first
- const status = await this.git.status();
- if (!status.isClean()) {
- console.log(chalk.yellow("Warning: You have uncommitted changes. Committing them first..."));
- await this.git.add("./*");
- await this.git.commit("Save work before checking out PR");
- console.log(chalk.green("✓ Committed uncommitted changes"));
- }
-
// 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);
- const actualBranchName = prData.headRefName;
-
- console.log(chalk.blue(`PR #${this.config.prNumber} uses branch: ${actualBranchName}`));
-
- // Fetch the PR
- await this.git.fetch('origin', `pull/${this.config.prNumber}/head:${actualBranchName}`);
-
- // Check if branch exists locally
- const branches = await this.git.branchLocal();
- if (branches.all.includes(actualBranchName)) {
- await this.git.checkout(actualBranchName);
- console.log(chalk.green(`✓ Switched to existing branch: ${actualBranchName}`));
- } else {
- await this.git.checkout(actualBranchName);
- console.log(chalk.green(`✓ Checked out PR #${this.config.prNumber} as branch: ${actualBranchName}`));
- }
+ branchName = prData.headRefName;
+ prFetchRef = `pull/${this.config.prNumber}/head:${branchName}`;
- branchName = actualBranchName;
+ 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 checkout PR #${this.config.prNumber}:`), error);
+ console.error(chalk.red(`✗ Failed to get PR #${this.config.prNumber} info:`), error);
throw error;
}
} else if (this.config.remoteBranch) {
- // Checkout remote branch
- console.log(chalk.blue(`Checking out remote branch: ${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 {
- // Check for uncommitted changes first
- const status = await this.git.status();
- if (!status.isClean()) {
- console.log(chalk.yellow("Warning: You have uncommitted changes. Committing them first..."));
- await this.git.add("./*");
- await this.git.commit("Save work before checking out remote branch");
- console.log(chalk.green("✓ Committed uncommitted changes"));
- }
-
// Parse remote/branch format
const parts = this.config.remoteBranch.split('/');
if (parts.length < 2) {
@@ -107,26 +78,10 @@ export class ClaudeSandbox {
const branch = parts.slice(1).join('/');
console.log(chalk.blue(`Remote: ${remote}, Branch: ${branch}`));
-
- // Fetch from remote
- await this.git.fetch(remote);
-
- // Check if local branch exists
- const branches = await this.git.branchLocal();
- if (branches.all.includes(branch)) {
- // Local branch exists, switch to it and pull
- await this.git.checkout(branch);
- await this.git.pull(remote, branch);
- console.log(chalk.green(`✓ Switched to existing local branch: ${branch}`));
- } else {
- // Create new local branch tracking the remote
- await this.git.checkoutBranch(branch, `${remote}/${branch}`);
- console.log(chalk.green(`✓ Created and checked out new branch: ${branch} tracking ${remote}/${branch}`));
- }
-
branchName = branch;
+ remoteFetchRef = `${remote}/${branch}`;
} catch (error) {
- console.error(chalk.red(`✗ Failed to checkout remote branch ${this.config.remoteBranch}:`), error);
+ console.error(chalk.red(`✗ Failed to parse remote branch ${this.config.remoteBranch}:`), error);
throw error;
}
} else {
@@ -150,6 +105,8 @@ export class ClaudeSandbox {
const containerConfig = await this.prepareContainer(
branchName,
credentials,
+ prFetchRef,
+ remoteFetchRef,
);
// Start container
@@ -203,6 +160,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);
@@ -213,6 +172,8 @@ export class ClaudeSandbox {
workDir,
repoName,
dockerImage: this.config.dockerImage || "claude-sandbox:latest",
+ prFetchRef,
+ remoteFetchRef,
};
}
From 7670871f386cbf9930d7beddf998a443e0053109 Mon Sep 17 00:00:00 2001
From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
Date: Sat, 7 Jun 2025 00:20:53 +0200
Subject: [PATCH 5/6] Checkpoint
---
src/git/shadow-repository.ts | 66 ++++++++++++++++++++++++++++++++++++
src/web-server.ts | 25 +++++++++++++-
2 files changed, 90 insertions(+), 1 deletion(-)
diff --git a/src/git/shadow-repository.ts b/src/git/shadow-repository.ts
index 4160f44..370df2f 100644
--- a/src/git/shadow-repository.ts
+++ b/src/git/shadow-repository.ts
@@ -292,6 +292,72 @@ export class ShadowRepository {
}
}
+ 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",
diff --git a/src/web-server.ts b/src/web-server.ts
index 8c7f85c..c671bac 100644
--- a/src/web-server.ts
+++ b/src/web-server.ts
@@ -453,6 +453,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(),
@@ -460,11 +461,33 @@ 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();
@@ -481,7 +504,7 @@ 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;
From 8014108b6a6dd93e798b8abe19a7fc6ba527cafe Mon Sep 17 00:00:00 2001
From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
Date: Sat, 7 Jun 2025 00:21:13 +0200
Subject: [PATCH 6/6] Checkpoint
---
public/app.js | 18 ++++----
public/index.html | 20 +++++++--
src/cli.ts | 5 +--
src/container.ts | 20 ++++++---
src/git/shadow-repository.ts | 87 +++++++++++++++++++++++++-----------
src/index.ts | 61 +++++++++++++++++--------
src/web-server.ts | 54 ++++++++++++++--------
7 files changed, 182 insertions(+), 83 deletions(-)
diff --git a/public/app.js b/public/app.js
index 240d2af..a01b5bf 100644
--- a/public/app.js
+++ b/public/app.js
@@ -646,7 +646,9 @@ function copySelection() {
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 url = containerId
+ ? `/api/git/info?containerId=${containerId}`
+ : "/api/git/info";
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
@@ -667,7 +669,7 @@ function updateGitInfo(data) {
if (data.currentBranch) {
// Clear existing content
branchNameElement.innerHTML = "";
-
+
if (data.branchUrl) {
// Create clickable branch link
const branchLink = document.createElement("a");
@@ -688,7 +690,7 @@ function updateGitInfo(data) {
// Fallback to plain text
branchNameElement.textContent = data.currentBranch;
}
-
+
gitInfoElement.style.display = "inline-block";
}
@@ -696,13 +698,13 @@ function updateGitInfo(data) {
prInfoElement.innerHTML = "";
if (data.prs && data.prs.length > 0) {
- data.prs.forEach(pr => {
+ 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");
@@ -717,7 +719,7 @@ function updateGitInfo(data) {
prBadge.classList.add("merged");
prBadge.textContent = `Merged PR #${pr.number}`;
}
-
+
prInfoElement.appendChild(prBadge);
});
}
@@ -730,10 +732,10 @@ document.addEventListener("DOMContentLoaded", () => {
initTerminal();
initSocket();
-
+
// Fetch git info on load
fetchGitInfo();
-
+
// Refresh git info periodically
setInterval(fetchGitInfo, 30000); // Every 30 seconds
diff --git a/public/index.html b/public/index.html
index 5b96f4f..dc9f41e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -485,12 +485,24 @@
Claude Code Sandbox
-
-