Fix deletion of committed files excluded in .gitignore, show branch info

This commit is contained in:
Onur Solmaz 2025-06-06 22:31:11 +02:00
parent 2087429514
commit 0703113f8d
6 changed files with 253 additions and 15 deletions

View file

@ -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:

View file

@ -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(

View file

@ -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>
@ -444,6 +485,13 @@
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>

View file

@ -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",
});

View file

@ -182,9 +182,9 @@ export class ShadowRepository {
}
}
private async prepareGitignoreExcludes(): Promise<void> {
private async prepareRsyncRules(): Promise<void> {
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 {

View file

@ -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 {