mirror of
https://github.com/textcortex/claude-code-sandbox.git
synced 2025-08-04 19:08:21 +00:00
Fix deletion of committed files excluded in .gitignore, show branch info
This commit is contained in:
parent
2087429514
commit
0703113f8d
6 changed files with 253 additions and 15 deletions
11
CLAUDE.md
11
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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue