Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ there is no "active" one. A project's **worktrees** are discovered from
`git worktree list`, never stored, so dropping the IDE on a repo finds its
worktrees already there.

A project can also be a **folder of repositories** -- a directory that is not a
checkout and holds several, for the work that spans them or comes before any of
them. It has no branches to cut, so it has one window rather than a row of
them: one Claude session in the folder itself, with its own todos, terminals
and files, and no changes of its own, since each repository in it keeps those.
That unit is a worktree like any other as far as everything downstream is
concerned -- which is what makes the whole thing small -- and its id is derived
from the folder's path, so nothing about it is stored either. The one fact that
*is* stored is that the project is a folder at all: a repository announces
itself and a folder of them looks like any directory. Opening one is offered
only when the directory holds checkouts, because initialising a repository
around them commits each as a bare reference to a commit and leaves the new
repository reporting them as modified for ever after.

A worktree is the unit, and it owns everything about itself: one Claude session,
any number of terminals, its todos, and its files and changes. A todo is a
prompt you park against a worktree; RUN NEXT hands it to the server, which types
Expand Down
36 changes: 36 additions & 0 deletions server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,42 @@ Anything destructive checks first and in the right order: `removeWorktree`
refuses a dirty worktree *before* killing its sessions, so a refusal costs
nothing.

**A folder project contributes one worktree that git never saw.** `Project.kind
=== 'folder'` is a directory holding checkouts rather than being one, and
`worktrees()` pushes a single synthetic unit for it -- `worktreeIdFor(root)`,
`branch: null`, `isMain: true` -- instead of calling `listWorktrees`. Modelling
it as a worktree is what makes it cost almost nothing: todos, the dispatcher,
sessions, the files tree, sleep and wake all key off the id and needed no
change at all. `isMain` is load-bearing, since it is what `removeWorktree`
refuses on.

Three things had to give way, each because git exits **128** outside a
repository and three callers did not expect it. `worktreeChanges` had the
file's one unwrapped `git status` and answered a 500 with a fatal in the body,
every three seconds, because the panel polls. `allFiles` (`git ls-files`) and
`grepFiles` (`git grep`) are the search box, which is where the keyboard lands
when the files panel opens -- so arriving in a folder's panel put the caret in
a control that 500s. Both now fall back to one bounded `readdir` walk that
skips `.git` and `GENERATED_DIRECTORIES`; the skip list is not cosmetic, since
the folder this was built for is 476MB and three of its four checkouts carry a
`node_modules`.

`openProject` must skip **both** `isGitRepo` and `repoRoot` on that path.
`isGitRepo` walks up, so a folder inside a checkout answers true, and `repoRoot`
then normalises to the ancestor -- asking for `<repo>/games` would quietly
register `<repo>`. And `describeProjects` suppresses `defaultBase`:
`resolveDefaultBase` is the one git helper that does not throw outside a
repository, falling through to `HEAD`, so left alone it names a base for
branches that cannot exist.

No `--session-id` is involved, and that was measured rather than assumed. With
one session per folder, `transcriptDir(cwd)` plus newest-transcript is exactly
how every worktree already works and is correct. It would only be needed for a
*second* session in one folder -- and it cannot simply be added for that:
`claude --session-id <uuid>` **refuses an id that already exists** ("Session ID
… is already in use"), so a deterministic per-path id could resume but never
start fresh, and persisted ids are the thing one unit per folder removes.

## The machine's own terminal

One shell that belongs to no worktree: `POST /api/sessions` with the reserved
Expand Down
186 changes: 177 additions & 9 deletions server/src/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,16 +288,99 @@ const changedPaths = async (worktreePath: string): Promise<Set<string>> => {
*/
const listFilesCache = new Map<string, { at: number; paths: string[] }>()

/**
* Heavy, machine-generated directories, skipped when there is no git to skip
* them for us.
*
* Shared with the first-commit warning in `workspace.ts`, because it is the
* same judgement: these are the directories nobody means when they say "the
* files here". Measured on the folder this was written for -- four game
* checkouts, 476MB -- three of them carry a `node_modules`, so a walk without
* this reads a third of a gigabyte on every keystroke.
*/
export const GENERATED_DIRECTORIES = [
'node_modules',
'.venv',
'venv',
'dist',
'build',
'target',
'.next',
'vendor',
'__pycache__',
]

/** Most files the walk will return. Past this, a search is not the tool. */
const MAX_WALK = 20000

/**
* Every file under a root, for a root git cannot answer for.
*
* The fallback behind `allFiles`, and the reason it exists is a folder of
* repositories: the folder itself is not a checkout, so `git ls-files` exits
* 128 there and the search box -- which is where the keyboard lands when the
* panel opens -- answered a 500.
*
* It cannot apply the ignore rules git would, and does not try to read the
* nested repositories' own: a file is interesting here or it is not, and the
* skip list above is what keeps that affordable. Breadth-first with a cap, so a
* deep tree costs the same as a wide one.
*/
const walkFiles = async (root: string): Promise<string[]> => {
const paths: string[] = []
const queue: string[] = ['']
while (queue.length > 0 && paths.length < MAX_WALK) {
const rel = queue.shift() as string
let entries: Dirent[]
try {
entries = await readdir(join(root, rel), { withFileTypes: true })
} catch {
// Unreadable, or gone since it was queued. A missing branch is not a
// reason to fail the whole search.
continue
}
for (const entry of entries) {
// `.git` by name, because in a linked worktree it is a file.
if (entry.name === '.git' || GENERATED_DIRECTORIES.includes(entry.name)) continue
const next = rel === '' ? entry.name : `${rel}/${entry.name}`
// Symlinks are not followed: a link to an ancestor is a cycle, and one
// pointing outside the root would list files the panel then refuses.
if (entry.isDirectory()) queue.push(next)
else if (entry.isFile()) {
if (paths.length >= MAX_WALK) break
paths.push(next)
}
}
}
return paths.sort()
}

/** Whether git refused because there is no repository here. */
const notARepoError = (err: unknown): boolean =>
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code?: unknown }).code === 128

const allFiles = async (worktreePath: string): Promise<string[]> => {
const now = Date.now()
const cached = listFilesCache.get(worktreePath)
if (cached !== undefined && now - cached.at < STATUS_TTL_MS) return cached.paths
const { stdout } = await exec(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ cwd: worktreePath, maxBuffer: 16 * 1024 * 1024 },
)
const paths = stdout.split('\0').filter((path) => path !== '')
let paths: string[]
try {
const { stdout } = await exec(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ cwd: worktreePath, maxBuffer: 16 * 1024 * 1024 },
)
paths = stdout.split('\0').filter((path) => path !== '')
} catch (err) {
// Asked rather than assumed: a folder project has no repository, and so
// does a worktree whose registration was pruned while its directory
// survived -- the case `checkIgnore` already tolerates below.
if (!notARepoError(err)) throw err
paths = await walkFiles(worktreePath)
}
listFilesCache.set(worktreePath, { at: now, paths })
return paths
}
Expand Down Expand Up @@ -397,13 +480,90 @@ export const findFiles = async (
const GREP_PER_FILE = 20
const GREP_TIMEOUT_MS = 5000

export const grepFiles = (
/** Largest file the fallback scan will read. Past this it is not prose. */
const SCAN_MAX_BYTES = 2 * 1024 * 1024

/** git exited 128: there is no repository at this root. */
class NotARepo extends Error {}

/**
* `grepFiles` for a root git cannot answer for -- see `walkFiles`.
*
* Reads the files the walk found and matches them here. Slower than `git grep`
* by a long way, which is why it is the fallback and not the implementation,
* and bounded the same three ways so that cost is capped: per file, overall,
* and in time.
*/
const scanFiles = async (
worktreePath: string,
needle: string,
): Promise<{ hits: ContentHit[]; truncated?: boolean; more?: string[] }> => {
const lowered = needle.toLowerCase()
const hits: ContentHit[] = []
const more = new Set<string>()
let truncated = false
const deadline = Date.now() + GREP_TIMEOUT_MS
for (const path of await allFiles(worktreePath)) {
if (hits.length >= MAX_FIND || Date.now() > deadline) {
truncated = true
break
}
let buffer: Buffer
try {
const info = await stat(join(worktreePath, path))
if (!info.isFile() || info.size > SCAN_MAX_BYTES) continue
buffer = await readFile(join(worktreePath, path))
} catch {
// Gone or unreadable since the walk listed it.
continue
}
// The same test `readTextFile` uses: a NUL early on says not text.
if (buffer.subarray(0, NUL_SCAN_BYTES).includes(0)) continue
const lines = buffer.toString('utf8').split('\n')
let inFile = 0
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] as string
if (!line.toLowerCase().includes(lowered)) continue
inFile += 1
if (inFile > GREP_PER_FILE) {
more.add(path)
break
}
const text = line.trim()
hits.push({ path, line: i + 1, text: text.length > 200 ? `${text.slice(0, 200)}…` : text })
if (hits.length >= MAX_FIND) {
truncated = true
break
}
}
}
return {
hits,
...(truncated ? { truncated: true } : {}),
...(more.size > 0 ? { more: [...more] } : {}),
}
}

export const grepFiles = async (
worktreePath: string,
query: string,
): Promise<{ hits: ContentHit[]; truncated?: boolean; more?: string[] }> => {
// Not trimmed, unlike a name: in a file, `x = ` and `x =` are different.
const needle = query
if (needle.trim() === '') return Promise.resolve({ hits: [] })
if (needle.trim() === '') return { hits: [] }
try {
return await gitGrep(worktreePath, needle)
} catch (err) {
if (!(err instanceof NotARepo)) throw err
return scanFiles(worktreePath, needle)
}
}

/** The git half of `grepFiles`, which is the fast path. */
const gitGrep = (
worktreePath: string,
needle: string,
): Promise<{ hits: ContentHit[]; truncated?: boolean; more?: string[] }> => {
return new Promise((resolvePromise, reject) => {
const child = spawn(
'git',
Expand Down Expand Up @@ -476,7 +636,15 @@ export const grepFiles = (
clearTimeout(timer)
// 1 is "nothing matched"; a kill of our own is not a failure either.
if (code !== 0 && code !== 1 && code !== null) {
reject(new Error(stderr.trim() || `git grep exited ${code}`))
// 128 is "not a git repository", which is a folder project's root and
// also a worktree whose registration was pruned under it. Named so the
// caller can fall back to its own scan instead of answering a 500 with
// a git fatal in the body.
reject(
code === 128
? new NotARepo(stderr.trim() || 'not a git repository')
: new Error(stderr.trim() || `git grep exited ${code}`),
)
return
}
resolvePromise({
Expand Down
29 changes: 26 additions & 3 deletions server/src/git/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,32 @@ export const worktreeChanges = async (opts: {
const branch = await currentBranch(opts.path)
const base = await resolveReviewBase(opts.root, branch)

const uncommitted = parseStatus(
await git(opts.path, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
)
/*
* The one unwrapped git call in this file, and it had to stop being one.
*
* A folder project's root is not a repository, so `git status` exits 128
* there and this answered a 500 with a git fatal in the body -- and the panel
* is polled, so it did so every three seconds. The honest answer for a root
* with no repository is the empty one: nothing uncommitted, nothing
* committed, no base to measure against. Every other read below already
* degrades this way.
*/
let uncommitted: FileChange[]
try {
uncommitted = parseStatus(
await git(opts.path, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
)
} catch {
return {
worktreeId: opts.worktreeId,
branch,
base: null,
uncommitted: [],
commits: [],
commitScope: 'recent',
behind: 0,
}
}
const nothingAhead = async (withBase: string | null): Promise<WorktreeChanges> => ({
worktreeId: opts.worktreeId,
branch,
Expand Down
34 changes: 33 additions & 1 deletion server/src/git/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createHash } from 'node:crypto'
import { appendFile, mkdir, readFile } from 'node:fs/promises'
import { access, appendFile, mkdir, readdir, readFile } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import type { Worktree } from '@switchboard/shared'

Expand Down Expand Up @@ -38,6 +38,38 @@ const idFor = (prefix: string, path: string): string =>
export const projectIdFor = (root: string): string => idFor('p', root)
export const worktreeIdFor = (path: string): string => idFor('wt', path)

/**
* The directories directly inside `path` that are themselves checkouts, sorted.
*
* What makes a folder worth opening as one: it is not a repository itself, but
* the things in it are. `.git` is tested for existence rather than kind,
* because in a linked worktree it is a file rather than a directory.
*
* One level only, and deliberately: this answers "is this a folder of
* repositories", which is a question about what you are looking at, not a
* search. Walking deeper would also mean walking into `node_modules`.
*/
export const containedRepos = async (path: string): Promise<string[]> => {
let entries
try {
entries = await readdir(path, { withFileTypes: true })
} catch {
return []
}
const found = await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory()) return null
try {
await access(join(path, entry.name, '.git'))
return entry.name
} catch {
return null
}
}),
)
return found.filter((name): name is string => name !== null).sort()
}

export const isGitRepo = async (path: string): Promise<boolean> => {
try {
const out = await git(path, 'rev-parse', '--is-inside-work-tree')
Expand Down
10 changes: 8 additions & 2 deletions server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ const openProjectBody = z.object({
create: z.boolean().default(false),
/** With `create`, put files already in the directory into the first commit. */
commitExisting: z.boolean().default(true),
/**
* Open a directory that is not a repository as a folder of them: one Claude
* session, no worktrees. Distinct from `create`, which makes it a repository
* instead -- the two are the opposite answers to the same refusal.
*/
folder: z.boolean().default(false),
})
const createWorktreeBody = z.object({
projectId: z.string().min(1),
Expand Down Expand Up @@ -284,8 +290,8 @@ export const registerApi = (app: FastifyInstance, deps: ApiDeps): void => {
app.get('/api/recents', async () => workspace.recentProjects())

app.post('/api/projects', async (request) => {
const { path, create, commitExisting } = openProjectBody.parse(request.body)
const project = await workspace.openProject(path, { create, commitExisting })
const { path, create, commitExisting, folder } = openProjectBody.parse(request.body)
const project = await workspace.openProject(path, { create, commitExisting, folder })
broadcastInvalidate()
return project
})
Expand Down
Loading
Loading