FN-7438: recover stale branch-group references
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>
This commit is contained in:
7
.changeset/fn-7438-branch-group-restart-recovery.md
Normal file
7
.changeset/fn-7438-branch-group-restart-recovery.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Recover stale task branch-group references from Task Detail after server restarts.
|
||||||
|
category: fix
|
||||||
|
dev: Adds branch_group restart regression coverage and non-origin integration-branch diagnostics.
|
||||||
@@ -1740,7 +1740,7 @@ The dashboard now exposes branch-group visibility and controls for shared planni
|
|||||||
|
|
||||||
- `GET /api/branch-groups` lists groups with completion (`landed`/`total`) and tracked PR metadata.
|
- `GET /api/branch-groups` lists groups with completion (`landed`/`total`) and tracked PR metadata.
|
||||||
- `GET /api/branch-groups/:id` returns group details (shared branch, members, per-member landed state, completion, PR state).
|
- `GET /api/branch-groups/:id` returns group details (shared branch, members, per-member landed state, completion, PR state).
|
||||||
- `POST /api/branch-groups/assign` is the supported online grouping path to attach/detach tasks (`{ taskId, groupId|null, branchName? }`).
|
- `POST /api/branch-groups/assign` is the supported online grouping path to attach/detach tasks (`{ taskId, groupId|null, branchName? }`). Passing `groupId: null` clears only that task's branch-group context and preserves unrelated task source metadata.
|
||||||
- `POST /api/branch-groups/:id/promote` triggers the engine promotion flow (`promoteBranchGroup`) and returns promotion/PR status.
|
- `POST /api/branch-groups/:id/promote` triggers the engine promotion flow (`promoteBranchGroup`) and returns promotion/PR status.
|
||||||
|
|
||||||
UI surfaces:
|
UI surfaces:
|
||||||
@@ -1749,9 +1749,10 @@ UI surfaces:
|
|||||||
- Task cards show grouped/shared branch metadata for grouped tasks.
|
- Task cards show grouped/shared branch metadata for grouped tasks.
|
||||||
- Clicking either grouped badge opens the dedicated **Group Task Modal** for that branch group.
|
- Clicking either grouped badge opens the dedicated **Group Task Modal** for that branch group.
|
||||||
- Task detail renders a branch-group card with member landed progress.
|
- Task detail renders a branch-group card with member landed progress.
|
||||||
|
- If a task references a stale/missing branch group, Task Detail shows a **Stale branch group reference** recovery message with **Reset branch group for this task**. The action uses the supported assign API to clear only the current task's context, then reloads the detail so the card disappears and the task can proceed ungrouped without raw SQLite surgery.
|
||||||
- In Task Detail Logs on mobile, the branch-group card includes a collapse/expand toggle so logs can reclaim vertical space while keeping group summary progress visible.
|
- In Task Detail Logs on mobile, the branch-group card includes a collapse/expand toggle so logs can reclaim vertical space while keeping group summary progress visible.
|
||||||
|
|
||||||
The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events.
|
The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. Branch groups are durable SQLite state keyed by real `BG-*` ids, so valid grouped tasks continue to list/show after a server restart. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events.
|
||||||
|
|
||||||
Both the modal and branch-group card are completion-gated: while members are still pending, they show progress only. PR / merge controls are only revealed after all members are landed into the shared branch. When auto-merge is off, promote/open-PR is explicit user action (no automatic push-to-origin behavior).
|
Both the modal and branch-group card are completion-gated: while members are still pending, they show progress only. PR / merge controls are only revealed after all members are landed into the shared branch. When auto-merge is off, promote/open-PR is explicit user action (no automatic push-to-origin behavior).
|
||||||
|
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
|
|||||||
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
|
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
|
||||||
| `mergeIntegrationWorktree` | `"reuse-task-worktree" \| "cwd-integration-branch" \| "cwd-main"` | `"reuse-task-worktree"` | Auto-merge integration-root mode for direct merges only (`mergeStrategy="direct"`). `reuse-task-worktree` (default) runs the rebase/conflict/audit/finalize cascade inside the task worktree after FN-5279 reuse-handoff gates, leaving project-root `HEAD`/dirty state untouched. `cwd-integration-branch` is an explicit operator opt-in escape hatch that runs the cascade from the resolved integration branch in the project-root worktree and surfaces an operator-visible startup warning per FN-5348. `cwd-main` is a deprecated legacy alias: `normalizeMergeIntegrationWorktreeMode(...)` normalizes it to `cwd-integration-branch` at read time and emits a one-shot `[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch` warning; new configs must not use it. When `worktrunk.enabled=true`, worktrunk-managed merge/worktree handling takes precedence and this setting is advisory until the native path runs. Reuse-handoff refusal must never silently fall back to `cwd-integration-branch`: any future fallback path must emit `merge:cwd-integration-fallback-removed`, and current behavior leaves the task in `in-review` instead. |
|
| `mergeIntegrationWorktree` | `"reuse-task-worktree" \| "cwd-integration-branch" \| "cwd-main"` | `"reuse-task-worktree"` | Auto-merge integration-root mode for direct merges only (`mergeStrategy="direct"`). `reuse-task-worktree` (default) runs the rebase/conflict/audit/finalize cascade inside the task worktree after FN-5279 reuse-handoff gates, leaving project-root `HEAD`/dirty state untouched. `cwd-integration-branch` is an explicit operator opt-in escape hatch that runs the cascade from the resolved integration branch in the project-root worktree and surfaces an operator-visible startup warning per FN-5348. `cwd-main` is a deprecated legacy alias: `normalizeMergeIntegrationWorktreeMode(...)` normalizes it to `cwd-integration-branch` at read time and emits a one-shot `[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch` warning; new configs must not use it. When `worktrunk.enabled=true`, worktrunk-managed merge/worktree handling takes precedence and this setting is advisory until the native path runs. Reuse-handoff refusal must never silently fall back to `cwd-integration-branch`: any future fallback path must emit `merge:cwd-integration-fallback-removed`, and current behavior leaves the task in `in-review` instead. |
|
||||||
| `mergeAdvanceAutoSync` | `"off" \| "ff-only" \| "stash-and-ff"` | `"stash-and-ff"` | After the merger advances the integration-branch ref, what to do in **other** worktrees still on that branch (typically your project-root checkout). `off` leaves them alone; users must `git pull` or click the Merge Advance Notice banner's Pull button to bring their checkout forward — this is the surprise behavior that made `git status` look like the merge had been reverted. `ff-only` auto-fast-forwards only when the other worktree's index and working tree are clean; dirty worktrees stay untouched and the banner still surfaces for manual pull. `stash-and-ff` (default) runs the Smart Pull pipeline (stash → fast-forward → pop) so local edits survive across the auto-sync. Pop conflicts emit `merge:auto-sync` audit events with `outcome: "stash-pop-conflict"` and surface through the dashboard's existing stash-conflict modal. Only applies to direct merges. |
|
| `mergeAdvanceAutoSync` | `"off" \| "ff-only" \| "stash-and-ff"` | `"stash-and-ff"` | After the merger advances the integration-branch ref, what to do in **other** worktrees still on that branch (typically your project-root checkout). `off` leaves them alone; users must `git pull` or click the Merge Advance Notice banner's Pull button to bring their checkout forward — this is the surprise behavior that made `git status` look like the merge had been reverted. `ff-only` auto-fast-forwards only when the other worktree's index and working tree are clean; dirty worktrees stay untouched and the banner still surfaces for manual pull. `stash-and-ff` (default) runs the Smart Pull pipeline (stash → fast-forward → pop) so local edits survive across the auto-sync. Pop conflicts emit `merge:auto-sync` audit events with `outcome: "stash-pop-conflict"` and surface through the dashboard's existing stash-conflict modal. Only applies to direct merges. |
|
||||||
| `integrationBranch` | `string` | `undefined` | Optional canonical project integration branch override. Resolution order for merge/self-healing/branch-conflict defaults is `integrationBranch` → legacy `baseBranch` → `origin/HEAD` symbolic ref → fallback `"main"`. This resolved value is used as `projectDefaultBranch` for `resolveTaskMergeTarget(...)`; task-level overrides still come from task metadata. |
|
| `integrationBranch` | `string` | `undefined` | Optional canonical project integration branch override. Resolution order for merge/self-healing/branch-conflict defaults is `integrationBranch` → legacy `baseBranch` → `origin/HEAD` symbolic ref → fallback `"main"`. Auto-detect intentionally checks `origin/HEAD` only; if a repository uses another remote (for example `gitlab`) and has no `origin` alias or `origin/HEAD`, Fusion logs a one-time warning naming discovered remotes and still falls back to `main`. Add an `origin` alias / `origin/HEAD` or set `integrationBranch` explicitly for non-`origin` remotes. This resolved value is used as `projectDefaultBranch` for `resolveTaskMergeTarget(...)`; task-level overrides still come from task metadata. |
|
||||||
| `prerebaseAutoEnabled` | `boolean` | `true` | Master switch for pre-merge auto-prerebase policy. When enabled, merger checks divergence from `<task.baseCommitSha>` to local `main` and may rebase before Stage 1/2 rebases. Ignored when `worktrunk.enabled=true` (worktrunk-managed path defers this layer). |
|
| `prerebaseAutoEnabled` | `boolean` | `true` | Master switch for pre-merge auto-prerebase policy. When enabled, merger checks divergence from `<task.baseCommitSha>` to local `main` and may rebase before Stage 1/2 rebases. Ignored when `worktrunk.enabled=true` (worktrunk-managed path defers this layer). |
|
||||||
| `prerebaseHotFiles` | `string[]` | `[`"AGENTS.md"`, `"packages/core/src/store.ts"`, `"packages/core/src/db.ts"`, `"packages/engine/src/executor.ts"`, `"packages/engine/src/scheduler.ts"`, `"packages/engine/src/merger.ts"`, `"packages/dashboard/app/styles.css"`]` | Exact-path trigger list for auto-prerebase. If any listed file appears in `<task.baseCommitSha>..localMainHead`, merger runs prerebase first, then continues through the existing Stage 1/2 cascade. Empty array disables hot-file triggering. |
|
| `prerebaseHotFiles` | `string[]` | `[`"AGENTS.md"`, `"packages/core/src/store.ts"`, `"packages/core/src/db.ts"`, `"packages/engine/src/executor.ts"`, `"packages/engine/src/scheduler.ts"`, `"packages/engine/src/merger.ts"`, `"packages/dashboard/app/styles.css"`]` | Exact-path trigger list for auto-prerebase. If any listed file appears in `<task.baseCommitSha>..localMainHead`, merger runs prerebase first, then continues through the existing Stage 1/2 cascade. Empty array disables hot-file triggering. |
|
||||||
| `prerebaseDivergenceThreshold` | `number` | `50` | Commit-count trigger for auto-prerebase. When `<task.baseCommitSha>..localMainHead` commit count is greater than this value, prerebase fires even without hot-file overlap. Set `0` (or unset) to disable threshold triggering. |
|
| `prerebaseDivergenceThreshold` | `number` | `50` | Commit-count trigger for auto-prerebase. When `<task.baseCommitSha>..localMainHead` commit count is greater than this value, prerebase fires even without hot-file overlap. Set `0` (or unset) to disable threshold triggering. |
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ Database Backup automation failures are surfaced with DB-qualified detail. Proje
|
|||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tasks` | Core task metadata and JSON-backed nested fields (priority, dependencies, steps, log, attachments, comments, model overrides, workflow results, merge details, assignment, mission linkage). |
|
| `tasks` | Core task metadata and JSON-backed nested fields (priority, dependencies, steps, log, attachments, comments, model overrides, workflow results, merge details, assignment, mission linkage). |
|
||||||
| `branch_groups` | Durable shared-branch group records keyed by `BG-*` id with source linkage (`mission`/`planning`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. |
|
| `branch_groups` | Durable shared-branch group records keyed by `BG-*` id with source linkage (`mission`/`planning`/`new-task`), branch/worktree metadata, optional PR tracking fields, lifecycle status, and per-group `autoMerge` override. This SQLite row is the authority for grouped-task reads after restart; task `sourceMetadata.fusionBranchContext.groupId` values should point at real `BG-*` rows, and stale per-task references are cleared through `TaskStore.setTaskBranchGroup(taskId, null)` / `POST /api/branch-groups/assign` rather than raw SQLite edits. |
|
||||||
| `mergeQueue` | Durable merge handoff queue keyed by `taskId`. Stores enqueue ordering (`enqueuedAt`, mirrored `priority`), single-owner lease state (`leasedBy`, `leasedAt`, `leaseExpiresAt`), and retry diagnostics (`attemptCount`, `lastError`). Leasing is priority-first + FIFO within priority, and expired leases are recoverable without incrementing attempts. FN-5242 adds the persistence/lease primitive; FN-5241 and FN-5243 wire executor enqueue + merger consumption. |
|
| `mergeQueue` | Durable merge handoff queue keyed by `taskId`. Stores enqueue ordering (`enqueuedAt`, mirrored `priority`), single-owner lease state (`leasedBy`, `leasedAt`, `leaseExpiresAt`), and retry diagnostics (`attemptCount`, `lastError`). Leasing is priority-first + FIFO within priority, and expired leases are recoverable without incrementing attempts. FN-5242 adds the persistence/lease primitive; FN-5241 and FN-5243 wire executor enqueue + merger consumption. |
|
||||||
|
|
||||||
FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor/self-healing path into `in-review` after execution finishes is `TaskStore.handoffToReview(...)`. That helper runs the column move, `mergeQueue` insert, and handoff audit fan-out inside one `BEGIN IMMEDIATE` transaction so observers never see `column = "in-review"` without the matching queue row. Direct `moveTask(taskId, "in-review")` writes remain allowed for explicit non-handoff/test paths but emit `task:handoff-invariant-violation` run-audit events unless the caller opts into the narrow allowlist flag.
|
FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor/self-healing path into `in-review` after execution finishes is `TaskStore.handoffToReview(...)`. That helper runs the column move, `mergeQueue` insert, and handoff audit fan-out inside one `BEGIN IMMEDIATE` transaction so observers never see `column = "in-review"` without the matching queue row. Direct `moveTask(taskId, "in-review")` writes remain allowed for explicit non-handoff/test paths but emit `task:handoff-invariant-violation` run-audit events unless the caller opts into the narrow allowlist flag.
|
||||||
|
|||||||
@@ -105,6 +105,42 @@ describe("TaskStore branch groups", () => {
|
|||||||
expect(store.getBranchGroup(group.id)?.sourceType).toBe("new-task");
|
expect(store.getBranchGroup(group.id)?.sourceType).toBe("new-task");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-7438: reloads durable branch groups and member tasks after store restart", async () => {
|
||||||
|
const group = store.ensureBranchGroupForSource("planning", "PS-restart", {
|
||||||
|
branchName: "feature/restart-shared",
|
||||||
|
autoMerge: true,
|
||||||
|
});
|
||||||
|
const memberA = await store.createTask({
|
||||||
|
description: "restart member a",
|
||||||
|
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
const memberB = await store.createTask({
|
||||||
|
description: "restart member b",
|
||||||
|
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
await store.createTask({ description: "ungrouped after restart" });
|
||||||
|
|
||||||
|
store.close();
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
expect(store.listBranchGroups().map((entry) => entry.id)).toContain(group.id);
|
||||||
|
expect(store.listBranchGroups({ status: "open" }).map((entry) => entry.id)).toContain(group.id);
|
||||||
|
expect(store.getBranchGroup(group.id)).toEqual(expect.objectContaining({
|
||||||
|
id: group.id,
|
||||||
|
branchName: "feature/restart-shared",
|
||||||
|
sourceType: "planning",
|
||||||
|
sourceId: "PS-restart",
|
||||||
|
autoMerge: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const members = await store.listTasksByBranchGroup(group.id);
|
||||||
|
expect(members.map((task) => task.id).sort()).toEqual([memberA.id, memberB.id].sort());
|
||||||
|
expect((await store.getTask(memberA.id)).sourceMetadata).toEqual({
|
||||||
|
fusionBranchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("enforces unique branchName", () => {
|
it("enforces unique branchName", () => {
|
||||||
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
|
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -173,7 +209,10 @@ describe("TaskStore branch groups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets and clears task branchContext via setTaskBranchGroup", async () => {
|
it("sets and clears task branchContext via setTaskBranchGroup", async () => {
|
||||||
const task = await store.createTask({ description: "branch link test" });
|
const task = await store.createTask({
|
||||||
|
description: "branch link test",
|
||||||
|
source: { sourceType: "api", sourceMetadata: { externalKey: "keep-me" } },
|
||||||
|
});
|
||||||
const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/planning" });
|
const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/planning" });
|
||||||
|
|
||||||
const onUpdated = vi.fn();
|
const onUpdated = vi.fn();
|
||||||
@@ -182,10 +221,12 @@ describe("TaskStore branch groups", () => {
|
|||||||
await store.setTaskBranchGroup(task.id, group.id);
|
await store.setTaskBranchGroup(task.id, group.id);
|
||||||
const linked = await store.getTask(task.id);
|
const linked = await store.getTask(task.id);
|
||||||
expect(linked.branchContext).toEqual({ groupId: group.id, source: "planning", assignmentMode: "shared" });
|
expect(linked.branchContext).toEqual({ groupId: group.id, source: "planning", assignmentMode: "shared" });
|
||||||
|
expect(linked.sourceMetadata).toEqual(expect.objectContaining({ externalKey: "keep-me" }));
|
||||||
|
|
||||||
await store.setTaskBranchGroup(task.id, null);
|
await store.setTaskBranchGroup(task.id, null);
|
||||||
const cleared = await store.getTask(task.id);
|
const cleared = await store.getTask(task.id);
|
||||||
expect(cleared.branchContext).toBeUndefined();
|
expect(cleared.branchContext).toBeUndefined();
|
||||||
|
expect(cleared.sourceMetadata).toEqual({ externalKey: "keep-me" });
|
||||||
expect(onUpdated).toHaveBeenCalled();
|
expect(onUpdated).toHaveBeenCalled();
|
||||||
|
|
||||||
await expect(store.setTaskBranchGroup(task.id, "BG-missing")).rejects.toThrow("not found");
|
await expect(store.setTaskBranchGroup(task.id, "BG-missing")).rejects.toThrow("not found");
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ Collapsed branch-group details must consume less task-detail space with token-ba
|
|||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-group-card-error-title {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-group-card-error-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.branch-group-card-header {
|
.branch-group-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,20 +3,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
|
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
|
||||||
import type { BranchGroupSummary } from "../api";
|
import type { BranchGroupSummary } from "../api";
|
||||||
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
|
import { ApiRequestError, apiAbandonBranchGroup, apiAssignTaskBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
|
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
|
||||||
|
|
||||||
interface BranchGroupCardProps {
|
interface BranchGroupCardProps {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
taskId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
onBranchGroupReset?: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
export function BranchGroupCard({ groupId, taskId, projectId, onBranchGroupReset }: BranchGroupCardProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
|
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [missingGroup, setMissingGroup] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
const [promoting, setPromoting] = useState(false);
|
const [promoting, setPromoting] = useState(false);
|
||||||
const [abandoning, setAbandoning] = useState(false);
|
const [abandoning, setAbandoning] = useState(false);
|
||||||
/*
|
/*
|
||||||
@@ -30,8 +34,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
|||||||
const response = await apiGetBranchGroup(groupId, projectId);
|
const response = await apiGetBranchGroup(groupId, projectId);
|
||||||
setGroup(response.group);
|
setGroup(response.group);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMissingGroup(false);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
|
const isMissing = loadError instanceof ApiRequestError && loadError.status === 404;
|
||||||
const message = loadError instanceof Error ? loadError.message : t("branchGroup.loadError", "Failed to load branch group");
|
const message = loadError instanceof Error ? loadError.message : t("branchGroup.loadError", "Failed to load branch group");
|
||||||
|
setMissingGroup(isMissing);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -96,11 +103,49 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
|||||||
}
|
}
|
||||||
}, [groupId, loadGroup, projectId]);
|
}, [groupId, loadGroup, projectId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BranchGroupRecovery 2026-07-02-11:55:
|
||||||
|
Stale branch-group references must be recoverable from task detail by clearing only the affected task through the supported branch-group assignment API. Do not require operators to stop Fusion or run raw SQLite json_remove repairs.
|
||||||
|
*/
|
||||||
|
const onResetStaleContext = useCallback(async () => {
|
||||||
|
if (!taskId) return;
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await apiAssignTaskBranchGroup({ taskId, groupId: null }, projectId);
|
||||||
|
await onBranchGroupReset?.();
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
}, [onBranchGroupReset, projectId, taskId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> {t("branchGroup.loading", "Loading branch group…")}</div>;
|
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> {t("branchGroup.loading", "Loading branch group…")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !group) {
|
if (error || !group) {
|
||||||
|
if (missingGroup) {
|
||||||
|
return (
|
||||||
|
<section className="card branch-group-card branch-group-card-error" aria-live="polite">
|
||||||
|
<div className="branch-group-card-error-title">{t("branchGroup.staleTitle", "Stale branch group reference")}</div>
|
||||||
|
<p className="branch-group-card-error-copy">
|
||||||
|
{t(
|
||||||
|
"branchGroup.staleDescription",
|
||||||
|
"This task references branch group {{groupId}}, but that group is no longer available. Reset only this task's branch group to continue without raw SQLite edits.",
|
||||||
|
{ groupId },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{taskId ? (
|
||||||
|
<button type="button" className="btn" onClick={() => void onResetStaleContext()} disabled={resetting}>
|
||||||
|
{resetting ? <Loader2 size={14} className="spin" /> : null}
|
||||||
|
{t("branchGroup.resetTask", "Reset branch group for this task")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="branch-group-card-error-copy">{t("branchGroup.resetUnavailable", "Open the task detail view to reset this stale branch group reference.")}</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="card branch-group-card branch-group-card-error">{error ?? t("branchGroup.unavailable", "Branch group unavailable")}</div>;
|
return <div className="card branch-group-card branch-group-card-error">{error ?? t("branchGroup.unavailable", "Branch group unavailable")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2517,6 +2517,12 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
}, [task.id, projectId, onTaskUpdated]);
|
}, [task.id, projectId, onTaskUpdated]);
|
||||||
|
|
||||||
|
const handleBranchGroupReset = useCallback(async () => {
|
||||||
|
const detail = await fetchTaskDetail(task.id, projectId);
|
||||||
|
setFullDetail(detail);
|
||||||
|
onTaskUpdated?.(detail);
|
||||||
|
}, [task.id, projectId, onTaskUpdated]);
|
||||||
|
|
||||||
const loadAgents = useCallback(async () => {
|
const loadAgents = useCallback(async () => {
|
||||||
setAgentsLoading(true);
|
setAgentsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -3484,7 +3490,13 @@ export function TaskDetailContent({
|
|||||||
</div>
|
</div>
|
||||||
{shouldShowBranchGroupCard && task.branchContext?.groupId && (
|
{shouldShowBranchGroupCard && task.branchContext?.groupId && (
|
||||||
/* FNXC:BranchGroupDetails 2026-06-30-00:00: Task-detail branch groups must return to their compact collapsed default when users switch tasks, including between members of the same shared branch group. Key by task and group so a manual expansion never leaks into the next task detail view. */
|
/* FNXC:BranchGroupDetails 2026-06-30-00:00: Task-detail branch groups must return to their compact collapsed default when users switch tasks, including between members of the same shared branch group. Key by task and group so a manual expansion never leaks into the next task detail view. */
|
||||||
<BranchGroupCard key={`${task.id}:${task.branchContext.groupId}`} groupId={task.branchContext.groupId} projectId={projectId} />
|
<BranchGroupCard
|
||||||
|
key={`${task.id}:${task.branchContext.groupId}`}
|
||||||
|
groupId={task.branchContext.groupId}
|
||||||
|
taskId={task.id}
|
||||||
|
projectId={projectId}
|
||||||
|
onBranchGroupReset={handleBranchGroupReset}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular
|
{/* FNXC:Workspace 2026-06-21-00:00: workspace tasks have no singular
|
||||||
task.worktree/task.branch; surface their acquired per-sub-repo worktrees
|
task.worktree/task.branch; surface their acquired per-sub-repo worktrees
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import React from "react";
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { BranchGroupCard } from "../BranchGroupCard";
|
import { BranchGroupCard } from "../BranchGroupCard";
|
||||||
|
import { ApiRequestError } from "../../api";
|
||||||
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||||
|
|
||||||
const apiGetBranchGroup = vi.fn();
|
const apiGetBranchGroup = vi.fn();
|
||||||
const apiPromoteBranchGroup = vi.fn();
|
const apiPromoteBranchGroup = vi.fn();
|
||||||
const apiAbandonBranchGroup = vi.fn();
|
const apiAbandonBranchGroup = vi.fn();
|
||||||
|
const apiAssignTaskBranchGroup = vi.fn();
|
||||||
|
|
||||||
type SseSubscription = {
|
type SseSubscription = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -19,9 +21,17 @@ type SseSubscription = {
|
|||||||
const sseSubscriptions: SseSubscription[] = [];
|
const sseSubscriptions: SseSubscription[] = [];
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
|
ApiRequestError: class ApiRequestError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
},
|
||||||
apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args),
|
apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args),
|
||||||
apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args),
|
apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args),
|
||||||
apiAbandonBranchGroup: (...args: unknown[]) => apiAbandonBranchGroup(...args),
|
apiAbandonBranchGroup: (...args: unknown[]) => apiAbandonBranchGroup(...args),
|
||||||
|
apiAssignTaskBranchGroup: (...args: unknown[]) => apiAssignTaskBranchGroup(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../sse-bus", () => ({
|
vi.mock("../../sse-bus", () => ({
|
||||||
@@ -74,6 +84,7 @@ describe("BranchGroupCard", () => {
|
|||||||
apiGetBranchGroup.mockReset();
|
apiGetBranchGroup.mockReset();
|
||||||
apiPromoteBranchGroup.mockReset();
|
apiPromoteBranchGroup.mockReset();
|
||||||
apiAbandonBranchGroup.mockReset();
|
apiAbandonBranchGroup.mockReset();
|
||||||
|
apiAssignTaskBranchGroup.mockReset();
|
||||||
sseSubscriptions.length = 0;
|
sseSubscriptions.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +96,35 @@ describe("BranchGroupCard", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-7438: guides users to reset a stale branch group reference and clears only this task", async () => {
|
||||||
|
apiGetBranchGroup.mockRejectedValue(new ApiRequestError("Branch group not found", 404));
|
||||||
|
apiAssignTaskBranchGroup.mockResolvedValue({ taskId: "FN-1", groupId: null });
|
||||||
|
const onBranchGroupReset = vi.fn();
|
||||||
|
|
||||||
|
render(<BranchGroupCard groupId="BG-missing" taskId="FN-1" projectId="proj-a" onBranchGroupReset={onBranchGroupReset} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Stale branch group reference")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Reset only this task's branch group/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /expand branch group/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||||
|
const reset = screen.getByRole("button", { name: /reset branch group for this task/i });
|
||||||
|
fireEvent.click(reset);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiAssignTaskBranchGroup).toHaveBeenCalledWith({ taskId: "FN-1", groupId: null }, "proj-a");
|
||||||
|
expect(onBranchGroupReset).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps transient branch-group load failures separate from stale-reference reset UI", async () => {
|
||||||
|
apiGetBranchGroup.mockRejectedValue(new Error("network down"));
|
||||||
|
|
||||||
|
render(<BranchGroupCard groupId="BG-1" taskId="FN-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("network down")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /reset branch group for this task/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows promote and calls API when complete + autoMerge off", async () => {
|
it("shows promote and calls API when complete + autoMerge off", async () => {
|
||||||
apiGetBranchGroup
|
apiGetBranchGroup
|
||||||
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }] }) })
|
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }, { taskId: "FN-2", title: "two", column: "done", landed: true }] }) })
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import {
|
|||||||
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
|
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
|
||||||
|
|
||||||
vi.mock("../BranchGroupCard", () => ({
|
vi.mock("../BranchGroupCard", () => ({
|
||||||
BranchGroupCard: ({ groupId }: { groupId: string }) => {
|
BranchGroupCard: ({ groupId, taskId, onBranchGroupReset }: { groupId: string; taskId?: string; onBranchGroupReset?: () => void }) => {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
Mock Branch Group {groupId}
|
Mock Branch Group {groupId}
|
||||||
|
{taskId && <span>Mock Branch Group Task {taskId}</span>}
|
||||||
<button type="button" onClick={() => setExpanded(true)}>Mock expand branch group</button>
|
<button type="button" onClick={() => setExpanded(true)}>Mock expand branch group</button>
|
||||||
|
{onBranchGroupReset && <button type="button" onClick={onBranchGroupReset}>Mock reset stale branch group</button>}
|
||||||
{expanded && <span>Mock branch group expanded</span>}
|
{expanded && <span>Mock branch group expanded</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -883,6 +885,13 @@ describe("TaskDetailModal branch group surfacing", () => {
|
|||||||
expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument();
|
expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-7438: passes task identity and reset callback to stale branch-group recovery", () => {
|
||||||
|
render(renderTaskWithBranchContext("FN-6041"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Mock Branch Group Task FN-6041")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Mock reset stale branch group" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("remounts the branch group card when switching tasks inside the same group", async () => {
|
it("remounts the branch group card when switching tasks inside the same group", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rerender } = render(renderTaskWithBranchContext("FN-6041"));
|
const { rerender } = render(renderTaskWithBranchContext("FN-6041"));
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { BranchGroup, Task, TaskStore } from "@fusion/core";
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { TaskStore } from "@fusion/core";
|
||||||
|
import type { BranchGroup, Task } from "@fusion/core";
|
||||||
import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine";
|
import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine";
|
||||||
import { createApiRoutes } from "../routes.js";
|
import { createApiRoutes } from "../routes.js";
|
||||||
import { createBranchGroupsRouter } from "../routes/register-branch-groups-routes.js";
|
import { createBranchGroupsRouter } from "../routes/register-branch-groups-routes.js";
|
||||||
@@ -216,6 +221,78 @@ describe("branch group routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("branch group routes with durable TaskStore", () => {
|
||||||
|
async function withRestartedStore<T>(callback: (store: TaskStore, app: express.Express) => Promise<T>): Promise<T> {
|
||||||
|
const rootDir = mkdtempSync(join(tmpdir(), "fusion-branch-group-route-"));
|
||||||
|
const globalDir = join(rootDir, ".fusion-global");
|
||||||
|
let store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
try {
|
||||||
|
const group = store.ensureBranchGroupForSource("planning", "PS-route-restart", {
|
||||||
|
branchName: "feature/route-restart",
|
||||||
|
autoMerge: true,
|
||||||
|
});
|
||||||
|
await store.createTask({
|
||||||
|
description: "route member after restart",
|
||||||
|
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
store.close();
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/branch-groups", createBranchGroupsRouter(store));
|
||||||
|
attachErrorHandler(app);
|
||||||
|
return await callback(store, app);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("FN-7438: lists and shows persisted branch groups after a server/store restart", async () => {
|
||||||
|
await withRestartedStore(async (store, app) => {
|
||||||
|
const group = store.getBranchGroupBySource("planning", "PS-route-restart");
|
||||||
|
expect(group?.id).toMatch(/^BG-/);
|
||||||
|
|
||||||
|
const listRes = await REQUEST(app, "GET", "/api/branch-groups");
|
||||||
|
expect(listRes.status).toBe(200);
|
||||||
|
expect(listRes.body.groups.map((entry: { id: string }) => entry.id)).toContain(group!.id);
|
||||||
|
expect(listRes.body.groups.find((entry: { id: string }) => entry.id === group!.id).completion.total).toBe(1);
|
||||||
|
|
||||||
|
const showRes = await REQUEST(app, "GET", `/api/branch-groups/${group!.id}`);
|
||||||
|
expect(showRes.status).toBe(200);
|
||||||
|
expect(showRes.body.group.id).toBe(group!.id);
|
||||||
|
expect(showRes.body.group.members).toHaveLength(1);
|
||||||
|
expect(showRes.body.group.branchName).toBe("feature/route-restart");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-7438: clears only one stale task branch context through the assign API", async () => {
|
||||||
|
await withRestartedStore(async (store, app) => {
|
||||||
|
const stale = await store.createTask({
|
||||||
|
description: "stale branch context",
|
||||||
|
source: { sourceType: "api", sourceMetadata: { externalKey: "preserve" } },
|
||||||
|
branchContext: { groupId: "BG-missing", source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
const peer = await store.createTask({
|
||||||
|
description: "peer branch context",
|
||||||
|
branchContext: { groupId: "BG-other-missing", source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(app, "POST", "/api/branch-groups/assign", JSON.stringify({ taskId: stale.id, groupId: null }), { "content-type": "application/json" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ taskId: stale.id, groupId: null });
|
||||||
|
|
||||||
|
const cleared = await store.getTask(stale.id);
|
||||||
|
expect(cleared.branchContext).toBeUndefined();
|
||||||
|
expect(cleared.sourceMetadata).toEqual({ externalKey: "preserve" });
|
||||||
|
expect((await store.getTask(peer.id)).branchContext?.groupId).toBe("BG-other-missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("branch group abandon (U6, R7)", () => {
|
describe("branch group abandon (U6, R7)", () => {
|
||||||
function buildOpenGroup(): BranchGroup {
|
function buildOpenGroup(): BranchGroup {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -85,6 +85,47 @@ describe("integration-branch resolver", () => {
|
|||||||
expect(first).toBe(INTEGRATION_BRANCH_FALLBACK);
|
expect(first).toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||||
expect(second).toBe(INTEGRATION_BRANCH_FALLBACK);
|
expect(second).toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||||
expect(warn).toHaveBeenCalledTimes(1);
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("origin/HEAD unset and no project override"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to main with actionable guidance when origin is absent but gitlab remote exists", async () => {
|
||||||
|
execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||||
|
if (command.includes("origin/HEAD")) {
|
||||||
|
cb(new Error("no symbolic ref"), { stdout: "" });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
cb(null, { stdout: "gitlab\n" });
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const warn = vi.fn();
|
||||||
|
|
||||||
|
const resolved = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } });
|
||||||
|
|
||||||
|
expect(resolved).toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain("auto-detect checks origin/HEAD");
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain("origin is absent");
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain("found remote gitlab");
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain("set integrationBranch manually");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns once per rootDir when origin exists but has no HEAD", async () => {
|
||||||
|
execMock.mockImplementation((command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
|
||||||
|
if (command.includes("origin/HEAD")) {
|
||||||
|
cb(new Error("no symbolic ref"), { stdout: "" });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
cb(null, { stdout: "origin\ngitlab\norigin\n" });
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const warn = vi.fn();
|
||||||
|
|
||||||
|
await expect(resolveIntegrationBranch("/repo", undefined, { logger: { warn } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||||
|
await expect(resolveIntegrationBranch("/repo", undefined, { logger: { warn } })).resolves.toBe(INTEGRATION_BRANCH_FALLBACK);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain("origin/HEAD is unset");
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain("found remote origin, gitlab");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sync and async variants match", async () => {
|
it("sync and async variants match", async () => {
|
||||||
|
|||||||
@@ -25,11 +25,21 @@ function normalize(value: unknown): string {
|
|||||||
.replace(/^origin\//, "");
|
.replace(/^origin\//, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function warnFallback(rootDir: string, logger: Pick<Console, "warn">): void {
|
/*
|
||||||
|
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)) {
|
if (warnedFallbackRootDirs.has(rootDir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
warnedFallbackRootDirs.add(rootDir);
|
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");
|
logger.warn("[integration-branch] falling back to 'main' — origin/HEAD unset and no project override");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +81,42 @@ function resolveFromOriginHeadSync(rootDir: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export async function resolveIntegrationBranch(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
settings: IntegrationBranchSettings,
|
settings: IntegrationBranchSettings,
|
||||||
@@ -88,7 +134,8 @@ export async function resolveIntegrationBranch(
|
|||||||
return fromOrigin;
|
return fromOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
warnFallback(rootDir, logger);
|
const remotes = await listGitRemotes(rootDir);
|
||||||
|
warnFallback(rootDir, logger, remotes);
|
||||||
return INTEGRATION_BRANCH_FALLBACK;
|
return INTEGRATION_BRANCH_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +156,8 @@ export function resolveIntegrationBranchSync(
|
|||||||
return fromOrigin;
|
return fromOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
warnFallback(rootDir, logger);
|
const remotes = listGitRemotesSync(rootDir);
|
||||||
|
warnFallback(rootDir, logger, remotes);
|
||||||
return INTEGRATION_BRANCH_FALLBACK;
|
return INTEGRATION_BRANCH_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user