From 385673f420697274d788c614a00fba4cd3f47f21 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 9 Mar 2026 12:04:04 -0700 Subject: [PATCH] Refactor symlink removal into unlinkSessionSymlink helper --- src/commands/cleanup.ts | 5 ++--- src/commands/helpers.ts | 30 +++++++++++++++++++++++------- src/commands/new.ts | 13 +++++++------ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/commands/cleanup.ts b/src/commands/cleanup.ts index 599b05b..51fd1dc 100644 --- a/src/commands/cleanup.ts +++ b/src/commands/cleanup.ts @@ -1,8 +1,7 @@ -import { join } from "path" import { existsSync } from "fs" -import { unlink } from "fs/promises" import * as git from "../git.ts" import * as state from "../state.ts" +import { unlinkSessionSymlink } from "./helpers.ts" export async function action() { const root = await git.repoRoot() @@ -23,7 +22,7 @@ export async function action() { for (const s of stale) { await state.removeSession(root, s.branch) - await unlink(join(root, '.sandlot', s.branch)).catch(() => {}) + await unlinkSessionSymlink(root, s.branch) console.log(`✔ Removed stale session: ${s.branch}`) } } diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 6270ea8..fbb45fa 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -1,6 +1,6 @@ -import { basename, join } from "path" +import { basename, dirname, join } from "path" import { homedir } from "os" -import { mkdir, symlink, unlink } from "fs/promises" +import { mkdir, readdir, rmdir, symlink, unlink } from "fs/promises" import { $ } from "bun" import * as git from "../git.ts" import * as vm from "../vm.ts" @@ -9,6 +9,22 @@ import { spinner } from "../spinner.ts" import { die } from "../fmt.ts" import type { Session } from "../state.ts" +/** Remove a .sandlot/ symlink and prune empty parent dirs up to .sandlot/. */ +export async function unlinkSessionSymlink(root: string, branch: string): Promise { + const sandlotDir = join(root, '.sandlot') + const symlinkPath = join(sandlotDir, branch) + await unlink(symlinkPath).catch(() => {}) + + // Walk up from the symlink's parent, removing empty dirs, stopping at .sandlot/ itself + let dir = dirname(symlinkPath) + while (dir !== sandlotDir) { + const entries = await readdir(dir).catch(() => null) + if (!entries || entries.length > 0) break + await rmdir(dir).catch(() => {}) + dir = dirname(dir) + } +} + /** Look up a session by branch, dying if it doesn't exist. */ export async function requireSession(branch: string): Promise<{ root: string; session: Session }> { const root = await git.repoRoot() @@ -35,12 +51,13 @@ export async function ensureSession(branch: string): Promise<{ root: string; ses const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch) try { await git.createWorktree(branch, worktreeAbs, root) - await mkdir(join(root, '.sandlot'), { recursive: true }) - await symlink(worktreeAbs, join(root, '.sandlot', branch)) + const symlinkPath = join(root, '.sandlot', branch) + await mkdir(dirname(symlinkPath), { recursive: true }) + await symlink(worktreeAbs, symlinkPath) } catch (err) { // Clean up on failure — but do NOT delete the branch (it already existed) await git.removeWorktree(worktreeAbs, root).catch(() => {}) - await unlink(join(root, '.sandlot', branch)).catch(() => {}) + await unlinkSessionSymlink(root, branch) die(`Failed to recreate session: ${(err as Error).message ?? err}`) } @@ -60,8 +77,7 @@ export async function teardownSession(root: string, branch: string, worktree: st await git.removeWorktree(worktree, root) .catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`)) - await unlink(join(root, '.sandlot', branch)) - .catch(() => {}) // symlink may not exist + await unlinkSessionSymlink(root, branch) await state.removeSession(root, branch) } diff --git a/src/commands/new.ts b/src/commands/new.ts index 5b76f8f..2d85e7e 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -1,6 +1,6 @@ -import { basename, join } from "path" +import { basename, dirname, join } from "path" import { homedir } from "os" -import { mkdir, symlink, unlink } from "fs/promises" +import { mkdir, symlink } from "fs/promises" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" @@ -8,7 +8,7 @@ import { spinner } from "../spinner.ts" import { die } from "../fmt.ts" import { requireApiKey } from "../env.ts" import { renderMarkdown } from "../markdown.ts" -import { saveChanges } from "./helpers.ts" +import { saveChanges, unlinkSessionSymlink } from "./helpers.ts" const ADJECTIVES = [ "calm", "bold", "warm", "cool", "keen", "soft", "fast", "wild", "fair", "rare", @@ -99,8 +99,9 @@ export async function action( const spin = spinner("Creating worktree", branch) try { await git.createWorktree(branch, worktreeAbs, root) - await mkdir(join(root, '.sandlot'), { recursive: true }) - await symlink(worktreeAbs, join(root, '.sandlot', branch)) + const symlinkPath = join(root, '.sandlot', branch) + await mkdir(dirname(symlinkPath), { recursive: true }) + await symlink(worktreeAbs, symlinkPath) spin.text = "Starting container" await vm.ensure((msg) => { spin.text = msg }) @@ -109,7 +110,7 @@ export async function action( spin.fail(String((err as Error).message ?? err)) await git.removeWorktree(worktreeAbs, root).catch(() => {}) await git.deleteLocalBranch(branch, root).catch(() => {}) - await unlink(join(root, '.sandlot', branch)).catch(() => {}) + await unlinkSessionSymlink(root, branch) process.exit(1) }