Recover branch-group detail views after restarts and let operators clear stale per-task group context safely. - Add Task Detail stale branch-group recovery UI that clears only the current task via the assign API. - Verify durable branch groups and members survive TaskStore/API restarts, while stale references can be reset without losing unrelated metadata. - Improve integration-branch fallback diagnostics for repositories that use non-origin remotes. - Document the recovery path, durable branch-group storage expectations, and release the fix as a patch. Files changed: .../fn-7438-branch-group-restart-recovery.md | 7 ++ docs/dashboard-guide.md | 5 +- docs/settings-reference.md | 2 +- docs/storage.md | 2 +- .../core/src/__tests__/branch-group-store.test.ts | 43 +++++++++++- .../dashboard/app/components/BranchGroupCard.css | 9 +++ .../dashboard/app/components/BranchGroupCard.tsx | 49 +++++++++++++- .../dashboard/app/components/TaskDetailModal.tsx | 14 +++- .../components/__tests__/BranchGroupCard.test.tsx | 40 +++++++++++ .../components/__tests__/TaskDetailModal.test.tsx | 11 ++- .../src/__tests__/routes-branch-groups.test.ts | 79 +++++++++++++++++++++- .../src/__tests__/integration-branch.test.ts | 41 +++++++++++ packages/engine/src/integration-branch.ts | 54 ++++++++++++++- 13 files changed, 343 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-7438 Fusion-Task-Lineage: 09ef3dc3-8319-49a7-9f4d-c95a3e069d39 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
167 lines
4.6 KiB
TypeScript
167 lines
4.6 KiB
TypeScript
import { exec, execSync } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type { ProjectSettings } from "@fusion/core";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
export type IntegrationBranchSettings =
|
|
| ProjectSettings
|
|
| (Pick<ProjectSettings, "integrationBranch"> & { baseBranch?: unknown })
|
|
| undefined
|
|
| null;
|
|
|
|
export const INTEGRATION_BRANCH_FALLBACK = "main";
|
|
const warnedFallbackRootDirs = new Set<string>();
|
|
|
|
function normalize(value: unknown): string {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
|
|
return value
|
|
.trim()
|
|
.replace(/^refs\/heads\//, "")
|
|
.replace(/^refs\/remotes\/origin\//, "")
|
|
.replace(/^origin\//, "");
|
|
}
|
|
|
|
/*
|
|
FNXC:IntegrationBranch 2026-07-02-11:59:
|
|
Auto-detect deliberately reads origin/HEAD only. When operators use another remote such as gitlab, fallback diagnostics must name discovered remotes and guide them to add an origin alias or set integrationBranch manually instead of silently choosing an arbitrary remote.
|
|
*/
|
|
function warnFallback(rootDir: string, logger: Pick<Console, "warn">, remotes: string[] = []): void {
|
|
if (warnedFallbackRootDirs.has(rootDir)) {
|
|
return;
|
|
}
|
|
warnedFallbackRootDirs.add(rootDir);
|
|
if (remotes.length > 0) {
|
|
const remoteList = remotes.join(", ");
|
|
const originState = remotes.includes("origin") ? "origin/HEAD is unset" : "origin is absent";
|
|
logger.warn(`[integration-branch] falling back to 'main' — auto-detect checks origin/HEAD, but ${originState}; found remote ${remoteList}. Add an origin alias or set integrationBranch manually.`);
|
|
return;
|
|
}
|
|
logger.warn("[integration-branch] falling back to 'main' — origin/HEAD unset and no project override");
|
|
}
|
|
|
|
function resolveFromSettings(settings: IntegrationBranchSettings): string {
|
|
const fromIntegration = normalize(settings?.integrationBranch);
|
|
if (fromIntegration.length > 0) {
|
|
return fromIntegration;
|
|
}
|
|
|
|
return normalize((settings as { baseBranch?: unknown } | null | undefined)?.baseBranch);
|
|
}
|
|
|
|
async function resolveFromOriginHead(rootDir: string): Promise<string> {
|
|
try {
|
|
const { stdout } = await execAsync("git symbolic-ref --short refs/remotes/origin/HEAD", {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return normalize(stdout);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function resolveFromOriginHeadSync(rootDir: string): string {
|
|
try {
|
|
const stdout = execSync("git symbolic-ref --short refs/remotes/origin/HEAD", {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
maxBuffer: 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
return normalize(stdout);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function parseRemotes(stdout: string): string[] {
|
|
return [...new Set(stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean))];
|
|
}
|
|
|
|
async function listGitRemotes(rootDir: string): Promise<string[]> {
|
|
try {
|
|
const { stdout } = await execAsync("git remote", {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return parseRemotes(stdout);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function listGitRemotesSync(rootDir: string): string[] {
|
|
try {
|
|
const stdout = execSync("git remote", {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
maxBuffer: 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
return parseRemotes(stdout);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function resolveIntegrationBranch(
|
|
rootDir: string,
|
|
settings: IntegrationBranchSettings,
|
|
opts: { logger?: Pick<Console, "warn"> } = {},
|
|
): Promise<string> {
|
|
const logger = opts.logger ?? console;
|
|
|
|
const fromSettings = resolveFromSettings(settings);
|
|
if (fromSettings.length > 0) {
|
|
return fromSettings;
|
|
}
|
|
|
|
const fromOrigin = await resolveFromOriginHead(rootDir);
|
|
if (fromOrigin.length > 0) {
|
|
return fromOrigin;
|
|
}
|
|
|
|
const remotes = await listGitRemotes(rootDir);
|
|
warnFallback(rootDir, logger, remotes);
|
|
return INTEGRATION_BRANCH_FALLBACK;
|
|
}
|
|
|
|
export function resolveIntegrationBranchSync(
|
|
rootDir: string,
|
|
settings: IntegrationBranchSettings,
|
|
opts: { logger?: Pick<Console, "warn"> } = {},
|
|
): string {
|
|
const logger = opts.logger ?? console;
|
|
|
|
const fromSettings = resolveFromSettings(settings);
|
|
if (fromSettings.length > 0) {
|
|
return fromSettings;
|
|
}
|
|
|
|
const fromOrigin = resolveFromOriginHeadSync(rootDir);
|
|
if (fromOrigin.length > 0) {
|
|
return fromOrigin;
|
|
}
|
|
|
|
const remotes = listGitRemotesSync(rootDir);
|
|
warnFallback(rootDir, logger, remotes);
|
|
return INTEGRATION_BRANCH_FALLBACK;
|
|
}
|
|
|
|
export function __resetIntegrationBranchCacheForTests(): void {
|
|
warnedFallbackRootDirs.clear();
|
|
}
|