framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
114 lines • 5.73 kB
JavaScript
import { basename, join } from 'node:path';
import { nodeGitRunner } from './project.js';
import { excludeFromGit } from './git-exclude.js';
import { FRAMEWORK_DIR, BRANCHES_DIR, worktreeDirEntries, currentBranch } from './store/index.js';
import { isWorktreeDirName } from './branch-names.js';
/** A {@link LinksFs} over `node:fs/promises`, dynamically imported like {@link nodeDirLister}. */
export function nodeLinksFs() {
const fs = () => import('node:fs/promises');
return {
readdir: dir => fs().then(f => f.readdir(dir)).catch(() => []),
mkdir: dir => fs().then(f => f.mkdir(dir, { recursive: true })).then(() => { }),
symlink: (target, path) => fs().then(f => f.symlink(target, path)),
readlink: path => fs().then(f => f.readlink(path)).catch(() => undefined),
unlink: path => fs().then(f => f.unlink(path)),
lexists: path => fs().then(f => f.lstat(path)).then(() => true, () => false),
};
}
/**
* Bring one project's `branches/` links in line with its worktrees: one link per worktree, named
* as the branch the worktree is on right now, plus the repo-root shortcut. Renames are covered by
* the same rule — the old name stops being wanted and is dropped, the new one is created.
*
* Touches only what is provably ours: a link is created, replaced, or removed only when it points
* (or would point) into `worktrees/`; anything else at those paths — a user's own file, dir, or
* symlink — is left alone. Never throws.
*/
export async function reconcileBranchLinks(cwd, deps = {}) {
const git = deps.git ?? nodeGitRunner();
const fs = deps.fs ?? nodeLinksFs();
const worktrees = deps.worktrees ?? worktreeDirEntries;
const branchOf = deps.branchOf ?? ((path) => currentBranch(path, git));
const exclude = deps.exclude ?? ((repo, rule) => excludeFromGit(repo, rule, undefined, git));
const linksDir = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR);
/** Link name -> relative target, derived from what is actually checked out. */
const wanted = new Map();
for (const entry of await worktrees(cwd).catch(() => [])) {
const branch = await branchOf(entry.path).catch(() => undefined);
// A detached worktree has no name to link; a slashed name (a pre-#1581 branch) cannot be a
// link name at all — both simply get no link.
if (!branch || branch.includes('/'))
continue;
// The link is a sibling of the checkout. A dir already carrying the branch's name needs none.
const target = basename(entry.path);
if (branch === target)
continue;
wanted.set(branch, target);
}
// Drop our stale links: entries that are symlinks to a sibling checkout but no longer wanted,
// or wanted with a different target (a reused branch name now belongs to a newer worktree).
for (const name of await fs.readdir(linksDir)) {
const path = join(linksDir, name);
const target = await fs.readlink(path);
if (target === undefined || !isWorktreeDirName(target))
continue; // not ours to touch
if (wanted.get(name) === target)
continue;
await fs.unlink(path).catch(() => { });
}
for (const [name, target] of wanted) {
const path = join(linksDir, name);
if (await fs.lexists(path))
continue; // ours-and-current, or someone else's — either way, stay
await fs.mkdir(linksDir);
await fs.symlink(target, path).catch(() => { });
}
// The repo-root `branches` shortcut (#1580). Relative, so a checkout that moves keeps working;
// created only when nothing sits at that path — a user's own `branches` file or dir is theirs.
// The link is framework state, so it is hidden from git the moment it is made (#1600):
// uncommitted at the root, it would ride any sweeping `git add -A` onto a code branch. The
// same exclude pair as the data checkout's `tickets` link: `/branches` hides root entries of
// that name, and `!/branches/` re-includes directories (a trailing slash never matches a
// symlink), so a user's own `branches` directory keeps committing while the link stays hidden.
const rootLink = join(cwd, 'branches');
if (!(await fs.lexists(rootLink))) {
await fs.mkdir(linksDir);
await fs.symlink(join(FRAMEWORK_DIR, BRANCHES_DIR), rootLink).catch(() => { });
await exclude(cwd, '/branches').catch(() => { });
await exclude(cwd, '!/branches/').catch(() => { });
}
}
/**
* Keep every registered project's branch links current, one turn per call. Runs on the daemon's
* clock; renames and reclaimed worktrees settle within a tick, and a freshly-allocated worktree
* gets its link immediately because allocation calls the reconcile too. Quiet on purpose: links
* are presentation, and narrating every rename would drown the log.
*/
export function startBranchLinksPass(opts) {
const reconcile = opts.reconcile ?? reconcileBranchLinks;
let stopped = false;
const passAll = async () => {
for (const project of await opts.projects().catch(() => [])) {
if (stopped)
break;
await reconcile(project.path).catch(() => { });
}
};
// Same overlap rule as the worktree sweep: awaiting `tick()` means the pass finished.
let inflight;
const tick = () => {
if (stopped)
return Promise.resolve();
inflight ??= passAll().finally(() => {
inflight = undefined;
});
return inflight;
};
return {
tick,
stop: () => {
stopped = true;
},
};
}
//# sourceMappingURL=branch-links.js.map