feat(server): self-identification tools

This commit is contained in:
Mohamed Boudra
2026-01-22 14:01:57 +07:00
parent cc2bdbf01f
commit ef9c538842
11 changed files with 557 additions and 175 deletions
+20
View File
@@ -13,6 +13,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
export class NotGitRepoError extends Error {
readonly cwd: string;
readonly code = "NOT_GIT_REPO";
constructor(cwd: string) {
super(`Not a git repository: ${cwd}`);
@@ -96,6 +97,25 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
return branch.length > 0 ? branch : null;
}
export async function renameCurrentBranch(
cwd: string,
newName: string
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
await requireRepoInfo(cwd);
const previousBranch = await getCurrentBranch(cwd);
if (!previousBranch || previousBranch === "HEAD") {
throw new Error("Cannot rename branch in detached HEAD state");
}
await execAsync(`git branch -m "${newName}"`, {
cwd,
});
const currentBranch = await getCurrentBranch(cwd);
return { previousBranch, currentBranch };
}
async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise<boolean> {
if (repoType === "bare") {
return false;
+55 -18
View File
@@ -35,6 +35,13 @@ export interface PaseoWorktreeInfo {
head?: string;
}
export type PaseoWorktreeOwnership = {
allowed: boolean;
repoRoot?: string;
worktreeRoot?: string;
worktreePath?: string;
};
interface CreateWorktreeOptions {
branchName: string;
cwd: string;
@@ -99,26 +106,26 @@ export async function detectRepoInfo(cwd: string): Promise<RepoInfo> {
}
}
// Check if we're in a normal git repository
const gitDirPath = join(cwd, ".git");
if (existsSync(gitDirPath)) {
try {
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const repoRoot = stdout.trim();
return {
type: "normal",
path: repoRoot,
name: basename(repoRoot),
};
} catch (error) {
throw new Error("Failed to determine git repository root");
// Fallback: allow running from any subdirectory inside a git checkout/worktree.
// Use git's common dir (shared .git) to find the repo root even when cwd has no .git entry.
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --git-common-dir",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const commonDir = stdout.trim();
if (!commonDir) {
throw new Error("git-common-dir was empty");
}
const repoRoot = basename(commonDir) === ".git" ? dirname(commonDir) : dirname(commonDir);
return {
type: "normal",
path: repoRoot,
name: basename(repoRoot),
};
} catch {
throw new Error("Not in a git repository");
}
throw new Error("Not in a git repository");
}
/**
@@ -195,6 +202,36 @@ function getPaseoWorktreesRoot(repoRoot: string): string {
return join(repoRoot, ".paseo", "worktrees");
}
export async function isPaseoOwnedWorktreeCwd(
cwd: string
): Promise<PaseoWorktreeOwnership> {
const repoInfo = await detectRepoInfo(cwd);
const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path);
const resolvedRoot = resolve(worktreesRoot) + sep;
const resolvedCwd = resolve(cwd);
if (!resolvedCwd.startsWith(resolvedRoot)) {
return {
allowed: false,
repoRoot: repoInfo.path,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
}
const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path });
const allowed = worktrees.some((entry) => {
const worktreePath = resolve(entry.path);
return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep);
});
return {
allowed,
repoRoot: repoInfo.path,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
}
function ensurePaseoIgnoredForRepo(repoInfo: RepoInfo): {
updated: boolean;
skipped: boolean;