mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 09:04:48 +00:00
Integrate OpenHandoff factory workspace (#212)
This commit is contained in:
parent
3d9476ed0b
commit
bf282199b5
251 changed files with 42824 additions and 692 deletions
246
factory/packages/backend/src/integrations/git-spice/index.ts
Normal file
246
factory/packages/backend/src/integrations/git-spice/index.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
interface SpiceCommand {
|
||||
command: string;
|
||||
prefix: string[];
|
||||
}
|
||||
|
||||
export interface SpiceStackEntry {
|
||||
branchName: string;
|
||||
parentBranch: string | null;
|
||||
}
|
||||
|
||||
function spiceCommands(): SpiceCommand[] {
|
||||
const explicit = process.env.HF_GIT_SPICE_BIN?.trim();
|
||||
const list: SpiceCommand[] = [];
|
||||
if (explicit) {
|
||||
list.push({ command: explicit, prefix: [] });
|
||||
}
|
||||
list.push({ command: "git-spice", prefix: [] });
|
||||
list.push({ command: "git", prefix: ["spice"] });
|
||||
return list;
|
||||
}
|
||||
|
||||
function commandLabel(cmd: SpiceCommand): string {
|
||||
return [cmd.command, ...cmd.prefix].join(" ");
|
||||
}
|
||||
|
||||
function looksMissing(error: unknown): boolean {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
detail.includes("ENOENT") ||
|
||||
detail.includes("not a git command") ||
|
||||
detail.includes("command not found")
|
||||
);
|
||||
}
|
||||
|
||||
async function tryRun(
|
||||
repoPath: string,
|
||||
cmd: SpiceCommand,
|
||||
args: string[]
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return await execFileAsync(cmd.command, [...cmd.prefix, ...args], {
|
||||
cwd: repoPath,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024 * 8,
|
||||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: "1",
|
||||
FORCE_COLOR: "0"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function pickCommand(repoPath: string): Promise<SpiceCommand | null> {
|
||||
for (const candidate of spiceCommands()) {
|
||||
try {
|
||||
await tryRun(repoPath, candidate, ["--help"]);
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if (looksMissing(error)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runSpice(repoPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
const cmd = await pickCommand(repoPath);
|
||||
if (!cmd) {
|
||||
throw new Error("git-spice is not available (set HF_GIT_SPICE_BIN or install git-spice)");
|
||||
}
|
||||
return await tryRun(repoPath, cmd, args);
|
||||
}
|
||||
|
||||
function parseLogJson(stdout: string): SpiceStackEntry[] {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: SpiceStackEntry[] = [];
|
||||
|
||||
// `git-spice log ... --json` prints one JSON object per line.
|
||||
for (const line of trimmed.split("\n")) {
|
||||
const raw = line.trim();
|
||||
if (!raw.startsWith("{")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw) as {
|
||||
name?: string;
|
||||
branch?: string;
|
||||
parent?: string | null;
|
||||
parentBranch?: string | null;
|
||||
};
|
||||
const branchName = (value.name ?? value.branch ?? "").trim();
|
||||
if (!branchName) {
|
||||
continue;
|
||||
}
|
||||
const parentRaw = value.parent ?? value.parentBranch ?? null;
|
||||
const parentBranch = parentRaw ? parentRaw.trim() || null : null;
|
||||
entries.push({ branchName, parentBranch });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((entry) => {
|
||||
if (seen.has(entry.branchName)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(entry.branchName);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function runFallbacks(repoPath: string, commands: string[][], errorContext: string): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const args of commands) {
|
||||
try {
|
||||
await runSpice(repoPath, args);
|
||||
return;
|
||||
} catch (error) {
|
||||
failures.push(`${args.join(" ")} :: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`${errorContext}. attempts=${failures.join(" | ")}`);
|
||||
}
|
||||
|
||||
export async function gitSpiceAvailable(repoPath: string): Promise<boolean> {
|
||||
return (await pickCommand(repoPath)) !== null;
|
||||
}
|
||||
|
||||
export async function gitSpiceListStack(repoPath: string): Promise<SpiceStackEntry[]> {
|
||||
try {
|
||||
const { stdout } = await runSpice(repoPath, [
|
||||
"log",
|
||||
"short",
|
||||
"--all",
|
||||
"--json",
|
||||
"--no-cr-status",
|
||||
"--no-prompt"
|
||||
]);
|
||||
return parseLogJson(stdout);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function gitSpiceSyncRepo(repoPath: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["repo", "sync", "--restack", "--no-prompt"],
|
||||
["repo", "sync", "--restack"],
|
||||
["repo", "sync"]
|
||||
],
|
||||
"git-spice repo sync failed"
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceRestackRepo(repoPath: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["repo", "restack", "--no-prompt"],
|
||||
["repo", "restack"]
|
||||
],
|
||||
"git-spice repo restack failed"
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceRestackSubtree(repoPath: string, branchName: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["upstack", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["upstack", "restack", "--branch", branchName],
|
||||
["branch", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["branch", "restack", "--branch", branchName]
|
||||
],
|
||||
`git-spice restack subtree failed for ${branchName}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceRebaseBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["branch", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["branch", "restack", "--branch", branchName]
|
||||
],
|
||||
`git-spice branch restack failed for ${branchName}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceReparentBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
parentBranch: string
|
||||
): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
|
||||
["upstack", "onto", "--branch", branchName, parentBranch],
|
||||
["branch", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
|
||||
["branch", "onto", "--branch", branchName, parentBranch]
|
||||
],
|
||||
`git-spice reparent failed for ${branchName} -> ${parentBranch}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceTrackBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
parentBranch: string
|
||||
): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["branch", "track", branchName, "--base", parentBranch, "--no-prompt"],
|
||||
["branch", "track", branchName, "--base", parentBranch]
|
||||
],
|
||||
`git-spice track failed for ${branchName}`
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeBaseBranchName(ref: string): string {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed) {
|
||||
return "main";
|
||||
}
|
||||
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||
}
|
||||
|
||||
export function describeSpiceCommandForLogs(repoPath: string): Promise<string | null> {
|
||||
return pickCommand(repoPath).then((cmd) => (cmd ? commandLabel(cmd) : null));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue