mirror of
https://github.com/textcortex/claude-code-sandbox.git
synced 2025-07-07 13:25:10 +00:00
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
This commit is contained in:
parent
2087429514
commit
4237d506d0
9 changed files with 606 additions and 39 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,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",
|
||||
|
|
|
@ -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,25 @@
|
|||
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>
|
||||
|
|
11
src/cli.ts
11
src/cli.ts
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
|
@ -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,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<void> {
|
||||
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 {
|
||||
|
|
101
src/index.ts
101
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<any> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,8 @@ export interface SandboxConfig {
|
|||
bashTimeout?: number;
|
||||
includeUntracked?: boolean;
|
||||
targetBranch?: string;
|
||||
remoteBranch?: string;
|
||||
prNumber?: string;
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue