Skip to content

Commit 4237d50

Browse files
authored
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
1 parent 2087429 commit 4237d50

File tree

9 files changed

+606
-39
lines changed

9 files changed

+606
-39
lines changed

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ This is the Claude Code Sandbox project - a CLI tool that runs Claude Code insta
6868
- Each session creates a new branch (`claude/[timestamp]`)
6969
- Real-time commit monitoring with interactive review
7070

71+
### Shadow Repository Sync Principles
72+
73+
The shadow repository maintains a real-time sync with the container's workspace using the following principles:
74+
75+
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`
76+
2. **Gitignore patterns apply to untracked files**: Files that are not committed to git but match `.gitignore` patterns will be excluded from sync
77+
3. **Built-in exclusions**: Certain directories (`.git`, `node_modules`, `__pycache__`, etc.) are always excluded for performance and safety
78+
4. **Rsync rule order**: Include rules for git-tracked files are processed before exclude rules, ensuring committed files are always preserved
79+
80+
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`.
81+
7182
## Configuration
7283

7384
The tool looks for `claude-sandbox.config.json` in the working directory. Key options:

public/app.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,9 @@ function initSocket() {
429429
if (term) {
430430
term.focus();
431431
}
432+
433+
// Fetch git info for this container
434+
fetchGitInfo();
432435
});
433436

434437
socket.on("output", (data) => {
@@ -639,6 +642,89 @@ function copySelection() {
639642
}
640643
}
641644

645+
// Git info functions
646+
async function fetchGitInfo() {
647+
try {
648+
// Use container ID if available to get branch from shadow repo
649+
const url = containerId
650+
? `/api/git/info?containerId=${containerId}`
651+
: "/api/git/info";
652+
const response = await fetch(url);
653+
if (response.ok) {
654+
const data = await response.json();
655+
updateGitInfo(data);
656+
} else {
657+
console.error("Failed to fetch git info:", response.statusText);
658+
}
659+
} catch (error) {
660+
console.error("Error fetching git info:", error);
661+
}
662+
}
663+
664+
function updateGitInfo(data) {
665+
const gitInfoElement = document.getElementById("git-info");
666+
const branchNameElement = document.getElementById("branch-name");
667+
const prInfoElement = document.getElementById("pr-info");
668+
669+
if (data.currentBranch) {
670+
// Clear existing content
671+
branchNameElement.innerHTML = "";
672+
673+
if (data.branchUrl) {
674+
// Create clickable branch link
675+
const branchLink = document.createElement("a");
676+
branchLink.href = data.branchUrl;
677+
branchLink.target = "_blank";
678+
branchLink.textContent = data.currentBranch;
679+
branchLink.style.color = "inherit";
680+
branchLink.style.textDecoration = "none";
681+
branchLink.title = `View ${data.currentBranch} branch on GitHub`;
682+
branchLink.addEventListener("mouseenter", () => {
683+
branchLink.style.textDecoration = "underline";
684+
});
685+
branchLink.addEventListener("mouseleave", () => {
686+
branchLink.style.textDecoration = "none";
687+
});
688+
branchNameElement.appendChild(branchLink);
689+
} else {
690+
// Fallback to plain text
691+
branchNameElement.textContent = data.currentBranch;
692+
}
693+
694+
gitInfoElement.style.display = "inline-block";
695+
}
696+
697+
// Clear existing PR info
698+
prInfoElement.innerHTML = "";
699+
700+
if (data.prs && data.prs.length > 0) {
701+
data.prs.forEach((pr) => {
702+
const prBadge = document.createElement("a");
703+
prBadge.className = "pr-badge";
704+
prBadge.href = pr.url;
705+
prBadge.target = "_blank";
706+
prBadge.title = pr.title;
707+
708+
// Set badge class based on state
709+
if (pr.isDraft) {
710+
prBadge.classList.add("draft");
711+
prBadge.textContent = `Draft PR #${pr.number}`;
712+
} else if (pr.state === "OPEN") {
713+
prBadge.classList.add("open");
714+
prBadge.textContent = `PR #${pr.number}`;
715+
} else if (pr.state === "CLOSED") {
716+
prBadge.classList.add("closed");
717+
prBadge.textContent = `Closed PR #${pr.number}`;
718+
} else if (pr.state === "MERGED") {
719+
prBadge.classList.add("merged");
720+
prBadge.textContent = `Merged PR #${pr.number}`;
721+
}
722+
723+
prInfoElement.appendChild(prBadge);
724+
});
725+
}
726+
}
727+
642728
// Initialize everything when DOM is ready
643729
document.addEventListener("DOMContentLoaded", () => {
644730
// Store original page title
@@ -647,6 +733,12 @@ document.addEventListener("DOMContentLoaded", () => {
647733
initTerminal();
648734
initSocket();
649735

736+
// Fetch git info on load
737+
fetchGitInfo();
738+
739+
// Refresh git info periodically
740+
setInterval(fetchGitInfo, 30000); // Every 30 seconds
741+
650742
// Initialize audio on first user interaction (browser requirement)
651743
document.addEventListener(
652744
"click",

public/index.html

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,47 @@
417417
margin-bottom: 1rem;
418418
font-size: 1.1rem;
419419
}
420+
421+
/* Git info styles */
422+
.git-info {
423+
color: #7d8590;
424+
font-size: 0.875rem;
425+
}
426+
427+
.pr-badge {
428+
display: inline-block;
429+
padding: 2px 6px;
430+
font-size: 12px;
431+
font-weight: 500;
432+
line-height: 1;
433+
border-radius: 3px;
434+
text-decoration: none;
435+
margin-left: 4px;
436+
}
437+
438+
.pr-badge.open {
439+
background-color: #2ea043;
440+
color: white;
441+
}
442+
443+
.pr-badge.draft {
444+
background-color: #6e7681;
445+
color: white;
446+
}
447+
448+
.pr-badge.closed {
449+
background-color: #8250df;
450+
color: white;
451+
}
452+
453+
.pr-badge.merged {
454+
background-color: #8250df;
455+
color: white;
456+
}
457+
458+
.pr-badge:hover {
459+
opacity: 0.8;
460+
}
420461
</style>
421462
</head>
422463
<body>
@@ -444,6 +485,25 @@ <h1>
444485
Claude Code Sandbox
445486
</h1>
446487
<div class="status">
488+
<span
489+
class="git-info"
490+
id="git-info"
491+
style="margin-right: 1rem; display: none"
492+
>
493+
<svg
494+
width="14"
495+
height="14"
496+
viewBox="0 0 16 16"
497+
fill="currentColor"
498+
style="vertical-align: text-bottom; margin-right: 4px"
499+
>
500+
<path
501+
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"
502+
/>
503+
</svg>
504+
<span id="branch-name">loading...</span>
505+
<span id="pr-info" style="margin-left: 0.5rem"></span>
506+
</span>
447507
<span class="status-indicator" id="status-indicator"></span>
448508
<span id="status-text">Connecting...</span>
449509
</div>

src/cli.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ program
8080
)
8181
.option("-n, --name <name>", "Container name prefix")
8282
.option("--no-push", "Disable automatic branch pushing")
83-
.option("--no-pr", "Disable automatic PR creation")
83+
.option("--no-create-pr", "Disable automatic PR creation")
8484
.option(
8585
"--include-untracked",
8686
"Include untracked files when copying to container",
@@ -89,6 +89,11 @@ program
8989
"-b, --branch <branch>",
9090
"Switch to specific branch on container start (creates if doesn't exist)",
9191
)
92+
.option(
93+
"--remote-branch <branch>",
94+
"Checkout a remote branch (e.g., origin/feature-branch)",
95+
)
96+
.option("--pr <number>", "Checkout a specific PR by number")
9297
.option(
9398
"--shell <shell>",
9499
"Start with 'claude' or 'bash' shell",
@@ -100,9 +105,11 @@ program
100105
const config = await loadConfig(options.config);
101106
config.containerPrefix = options.name || config.containerPrefix;
102107
config.autoPush = options.push !== false;
103-
config.autoCreatePR = options.pr !== false;
108+
config.autoCreatePR = options.createPr !== false;
104109
config.includeUntracked = options.includeUntracked || false;
105110
config.targetBranch = options.branch;
111+
config.remoteBranch = options.remoteBranch;
112+
config.prNumber = options.pr;
106113
if (options.shell) {
107114
config.defaultShell = options.shell.toLowerCase();
108115
}

src/container.ts

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ export class ContainerManager {
5050
console.log(chalk.green("✓ Container ready"));
5151

5252
// Set up git branch and startup script
53-
await this.setupGitAndStartupScript(container, containerConfig.branchName);
53+
await this.setupGitAndStartupScript(
54+
container,
55+
containerConfig.branchName,
56+
containerConfig.prFetchRef,
57+
containerConfig.remoteFetchRef,
58+
);
5459

5560
// Run setup commands
5661
await this.runSetupCommands(container);
@@ -497,6 +502,18 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
497502
const { execSync } = require("child_process");
498503
const fs = require("fs");
499504

505+
// Helper function to get tar flags safely
506+
const getTarFlags = () => {
507+
try {
508+
// Test if --no-xattrs is supported by checking tar help
509+
execSync("tar --help 2>&1 | grep -q no-xattrs", { stdio: "pipe" });
510+
return "--no-xattrs";
511+
} catch {
512+
// --no-xattrs not supported, use standard tar
513+
return "";
514+
}
515+
};
516+
500517
try {
501518
// Get list of git-tracked files (including uncommitted changes)
502519
const trackedFiles = execSync("git ls-files", {
@@ -575,8 +592,9 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
575592
const gitTarFile = `/tmp/claude-sandbox-git-${Date.now()}.tar`;
576593
// Exclude macOS resource fork files and .DS_Store when creating git archive
577594
// Also strip extended attributes to prevent macOS xattr issues in Docker
595+
const tarFlags = getTarFlags();
578596
execSync(
579-
`tar -cf "${gitTarFile}" --exclude="._*" --exclude=".DS_Store" --no-xattrs .git`,
597+
`tar -cf "${gitTarFile}" --exclude="._*" --exclude=".DS_Store" ${tarFlags} .git`,
580598
{
581599
cwd: workDir,
582600
stdio: "pipe",
@@ -615,6 +633,18 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
615633
const path = require("path");
616634
const { execSync } = require("child_process");
617635

636+
// Helper function to get tar flags safely
637+
const getTarFlags = () => {
638+
try {
639+
// Test if --no-xattrs is supported by checking tar help
640+
execSync("tar --help 2>&1 | grep -q no-xattrs", { stdio: "pipe" });
641+
return "--no-xattrs";
642+
} catch {
643+
// --no-xattrs not supported, use standard tar
644+
return "";
645+
}
646+
};
647+
618648
try {
619649
// First, try to get credentials from macOS Keychain if on Mac
620650
if (process.platform === "darwin") {
@@ -754,9 +784,13 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
754784
console.log(chalk.blue("• Copying .claude directory..."));
755785

756786
const tarFile = `/tmp/claude-dir-${Date.now()}.tar`;
757-
execSync(`tar -cf "${tarFile}" --no-xattrs -C "${os.homedir()}" .claude`, {
758-
stdio: "pipe",
759-
});
787+
const tarFlags = getTarFlags();
788+
execSync(
789+
`tar -cf "${tarFile}" ${tarFlags} -C "${os.homedir()}" .claude`,
790+
{
791+
stdio: "pipe",
792+
},
793+
);
760794

761795
const stream = fs.createReadStream(tarFile);
762796
await container.putArchive(stream, {
@@ -869,6 +903,8 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
869903
private async setupGitAndStartupScript(
870904
container: any,
871905
branchName: string,
906+
prFetchRef?: string,
907+
remoteFetchRef?: string,
872908
): Promise<void> {
873909
console.log(chalk.blue("• Setting up git branch and startup script..."));
874910

@@ -916,13 +952,37 @@ exec /bin/bash`;
916952
git config --global url."https://\${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:"
917953
echo "✓ Configured git to use GitHub token"
918954
fi &&
919-
# Try to checkout existing branch first, then create new if it doesn't exist
920-
if git show-ref --verify --quiet refs/heads/"${branchName}"; then
921-
git checkout "${branchName}" &&
922-
echo "✓ Switched to existing branch: ${branchName}"
955+
# Handle different branch setup scenarios
956+
if [ -n "${prFetchRef || ""}" ]; then
957+
echo "• Fetching PR branch..." &&
958+
git fetch origin ${prFetchRef} &&
959+
if git show-ref --verify --quiet refs/heads/"${branchName}"; then
960+
git checkout "${branchName}" &&
961+
echo "✓ Switched to existing PR branch: ${branchName}"
962+
else
963+
git checkout "${branchName}" &&
964+
echo "✓ Checked out PR branch: ${branchName}"
965+
fi
966+
elif [ -n "${remoteFetchRef || ""}" ]; then
967+
echo "• Fetching remote branch..." &&
968+
git fetch origin &&
969+
if git show-ref --verify --quiet refs/heads/"${branchName}"; then
970+
git checkout "${branchName}" &&
971+
git pull origin "${branchName}" &&
972+
echo "✓ Switched to existing remote branch: ${branchName}"
973+
else
974+
git checkout -b "${branchName}" "${remoteFetchRef}" &&
975+
echo "✓ Created local branch from remote: ${branchName}"
976+
fi
923977
else
924-
git checkout -b "${branchName}" &&
925-
echo "✓ Created new branch: ${branchName}"
978+
# Regular branch creation
979+
if git show-ref --verify --quiet refs/heads/"${branchName}"; then
980+
git checkout "${branchName}" &&
981+
echo "✓ Switched to existing branch: ${branchName}"
982+
else
983+
git checkout -b "${branchName}" &&
984+
echo "✓ Created new branch: ${branchName}"
985+
fi
926986
fi &&
927987
cat > /home/claude/start-session.sh << 'EOF'
928988
${startupScript}
@@ -947,7 +1007,11 @@ EOF
9471007
setupStream.on("end", () => {
9481008
if (
9491009
(output.includes("✓ Created new branch") ||
950-
output.includes("✓ Switched to existing branch")) &&
1010+
output.includes("✓ Switched to existing branch") ||
1011+
output.includes("✓ Switched to existing remote branch") ||
1012+
output.includes("✓ Switched to existing PR branch") ||
1013+
output.includes("✓ Checked out PR branch") ||
1014+
output.includes("✓ Created local branch from remote")) &&
9511015
output.includes("✓ Startup script created")
9521016
) {
9531017
resolve();

0 commit comments

Comments
 (0)