Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@ function initSocket() {
if (term) {
term.focus();
}

// Fetch git info for this container
fetchGitInfo();
});

socket.on("output", (data) => {
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</style>
</head>
<body>
Expand Down Expand Up @@ -444,6 +485,25 @@ <h1>
Claude Code Sandbox
</h1>
<div class="status">
<span
class="git-info"
id="git-info"
style="margin-right: 1rem; display: none"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="currentColor"
style="vertical-align: text-bottom; margin-right: 4px"
>
<path
d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"
/>
</svg>
<span id="branch-name">loading...</span>
<span id="pr-info" style="margin-left: 0.5rem"></span>
</span>
<span class="status-indicator" id="status-indicator"></span>
<span id="status-text">Connecting...</span>
</div>
Expand Down
11 changes: 9 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ program
)
.option("-n, --name <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",
Expand All @@ -89,6 +89,11 @@ program
"-b, --branch <branch>",
"Switch to specific branch on container start (creates if doesn't exist)",
)
.option(
"--remote-branch <branch>",
"Checkout a remote branch (e.g., origin/feature-branch)",
)
.option("--pr <number>", "Checkout a specific PR by number")
.option(
"--shell <shell>",
"Start with 'claude' or 'bash' shell",
Expand All @@ -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();
}
Expand Down
88 changes: 76 additions & 12 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -869,6 +903,8 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
private async setupGitAndStartupScript(
container: any,
branchName: string,
prFetchRef?: string,
remoteFetchRef?: string,
): Promise<void> {
console.log(chalk.blue("• Setting up git branch and startup script..."));

Expand Down Expand Up @@ -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}
Expand All @@ -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();
Expand Down
Loading