Fix deletion of committed files excluded in .gitignore, show branch info (#7)
Some checks failed
Build and Test / build (20.x) (push) Has been cancelled
Build and Test / build (18.x) (push) Has been cancelled
Build and Test / build (22.x) (push) Has been cancelled

* 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:
Onur Solmaz 2025-06-07 01:22:15 +03:00 committed by GitHub
parent 2087429514
commit 4237d506d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 606 additions and 39 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,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",

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,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>

View file

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

View file

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

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

View file

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

View file

@ -23,6 +23,8 @@ export interface SandboxConfig {
bashTimeout?: number;
includeUntracked?: boolean;
targetBranch?: string;
remoteBranch?: string;
prNumber?: string;
}
export interface Credentials {

View file

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