feat(dashboard): extended integration-branch status in Git Manager
Repository Status panel now answers "what is the actual state of my
project root vs the integration branch?" so operators can be sure of
the picture even when the Merge Advance Notice banner is dismissed.
GET /api/git/status accepts ?extended=1 and returns additional optional
fields:
- integrationBranch + integrationBranchSource (settings|origin-head|fallback)
- integrationTipSha / originIntegrationTipSha
- aheadOfIntegration / behindIntegration (HEAD vs local integration tip)
- aheadOfOriginIntegration / behindOriginIntegration (local tip vs origin)
- dirtyDetails {staged, modified, untracked, conflicted, sample}
- indexStaleVsHead (surfaces the FN-INDEX-DESYNC scenario)
- stashCount
- recentMergeAdvances: up to 5 merge:integration-ref-advance events
joined with merge:auto-sync outcomes; needsAction flag flips when
auto-sync didn't successfully bring this worktree forward
GitManagerModal renders all of it:
- Existing cards get sub-text: branch shows "not on <integration>",
Working Tree shows staged/modified/untracked/conflicted breakdown
- Second row: Integration branch + source, HEAD-vs-integration,
local-vs-origin, stash count
- Yellow warning panel when indexStaleVsHead surfaces the merger's
stale-index situation with a recovery hint
- Recent integration-branch advances list, color-coded by needsAction,
shows the per-advance auto-sync outcome so operators can audit
even after dismissing the banner
All fetchGitStatus calls in GitManagerModal switched to extended:true.
Other callers unaffected — extra fields are optional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
.changeset/git-manager-extended-status.md
Normal file
27
.changeset/git-manager-extended-status.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
"@fusion/dashboard": minor
|
||||
---
|
||||
|
||||
feat(dashboard): show extended integration-branch + working-tree state in Git Manager
|
||||
|
||||
Repository Status panel now answers "what is the actual state of my project root vs the integration branch?" so operators can be sure of the picture even when the Merge Advance Notice banner has been dismissed.
|
||||
|
||||
`GET /api/git/status` accepts a new `?extended=1` query and returns additional optional fields:
|
||||
|
||||
- **integrationBranch** + **integrationBranchSource** — the canonical branch (resolved via `settings.integrationBranch` → legacy `baseBranch` → `origin/HEAD` → `main`) and where the value came from.
|
||||
- **integrationTipSha / originIntegrationTipSha** — SHAs at both ends, so operators can spot when local main has been advanced by the merger but origin/main hasn't caught up.
|
||||
- **aheadOfIntegration / behindIntegration** — HEAD vs local integration tip (useful when on a non-integration branch).
|
||||
- **aheadOfOriginIntegration / behindOriginIntegration** — local integration tip vs `origin/<branch>`.
|
||||
- **dirtyDetails** — staged/modified/untracked/conflicted counts + a 12-line porcelain sample.
|
||||
- **indexStaleVsHead** — true when the index reflects a previous tip and the worktree is clean against the index but not against HEAD. Surfaces the exact "phantom staged changes" scenario that `mergeAdvanceAutoSync` exists to fix.
|
||||
- **stashCount** — for at-a-glance recovery awareness.
|
||||
- **recentMergeAdvances** — up to 5 recent `merge:integration-ref-advance` audit events for the project root, joined with their `merge:auto-sync` outcomes; entries whose auto-sync didn't successfully bring this worktree forward are flagged `needsAction: true`.
|
||||
|
||||
`GitManagerModal` now renders all of this:
|
||||
|
||||
- The existing Branch / Commit / Working Tree / Remote Sync cards gain sub-text — Working Tree shows staged/modified/untracked/conflicted breakdown; Branch shows whether you're on the integration branch.
|
||||
- A second row of cards adds Integration branch (with resolution source + tip SHA), HEAD-vs-integration ahead/behind, local-integration-vs-origin ahead/behind, and stash count.
|
||||
- A yellow warning panel appears when `indexStaleVsHead` is true, telling the operator to enable `mergeAdvanceAutoSync` or run `git reset --hard HEAD`.
|
||||
- A "Recent integration-branch advances" list shows the last few merger advances with their per-advance auto-sync outcome, color-coded by whether they still need action.
|
||||
|
||||
All `fetchGitStatus(projectId)` calls inside `GitManagerModal` now pass `{ extended: true }`. Other callers in the app are unaffected — the extra fields are optional and the un-extended response shape is unchanged.
|
||||
@@ -2578,6 +2578,34 @@ export interface GitStatus {
|
||||
isDirty: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
// Returned only when `?extended=1` is passed to GET /api/git/status.
|
||||
headSha?: string;
|
||||
integrationBranch?: string;
|
||||
integrationBranchSource?: "settings" | "origin-head" | "fallback";
|
||||
isOnIntegrationBranch?: boolean;
|
||||
integrationTipSha?: string | null;
|
||||
originIntegrationTipSha?: string | null;
|
||||
aheadOfIntegration?: number;
|
||||
behindIntegration?: number;
|
||||
aheadOfOriginIntegration?: number;
|
||||
behindOriginIntegration?: number;
|
||||
dirtyDetails?: {
|
||||
staged: number;
|
||||
modified: number;
|
||||
untracked: number;
|
||||
conflicted: number;
|
||||
sample: string[];
|
||||
};
|
||||
indexStaleVsHead?: boolean;
|
||||
stashCount?: number;
|
||||
recentMergeAdvances?: Array<{
|
||||
taskId: string;
|
||||
fromSha: string | null;
|
||||
toSha: string;
|
||||
advancedAt: string;
|
||||
autoSyncOutcome?: string;
|
||||
needsAction: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Git commit info */
|
||||
@@ -2630,9 +2658,15 @@ export interface GitPushResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Fetch current git status */
|
||||
export function fetchGitStatus(projectId?: string): Promise<GitStatus> {
|
||||
return api<GitStatus>(withProjectId("/git/status", projectId));
|
||||
/** Fetch current git status. Pass `extended` to also get integration-branch
|
||||
* resolution, ahead/behind vs both local and origin integration tip, dirty
|
||||
* breakdown, stash count, index-stale detection, and recent merge-advance
|
||||
* audit events for the project-root worktree. */
|
||||
export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }): Promise<GitStatus> {
|
||||
const base = withProjectId("/git/status", projectId);
|
||||
if (!opts?.extended) return api<GitStatus>(base);
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return api<GitStatus>(`${base}${sep}extended=1`);
|
||||
}
|
||||
|
||||
/** Fetch recent commits */
|
||||
|
||||
@@ -273,12 +273,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
try {
|
||||
switch (activeSection) {
|
||||
case "status": {
|
||||
const statusData = await fetchGitStatus(projectId);
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
setStatus(statusData);
|
||||
break;
|
||||
}
|
||||
case "changes": {
|
||||
const [statusData, changes] = await Promise.all([fetchGitStatus(projectId), fetchFileChanges(projectId)]);
|
||||
const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchFileChanges(projectId)]);
|
||||
setStatus(statusData);
|
||||
setFileChanges(changes);
|
||||
setSelectedFiles(new Set());
|
||||
@@ -293,7 +293,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
break;
|
||||
}
|
||||
case "branches": {
|
||||
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId)]);
|
||||
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
setBranches(branchesData);
|
||||
setStatus(statusForBranch);
|
||||
break;
|
||||
@@ -313,7 +313,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
break;
|
||||
}
|
||||
case "remotes": {
|
||||
const remoteStatus = await fetchGitStatus(projectId);
|
||||
const remoteStatus = await fetchGitStatus(projectId, { extended: true });
|
||||
setStatus(remoteStatus);
|
||||
break;
|
||||
}
|
||||
@@ -398,7 +398,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
try {
|
||||
await discardChanges(files, projectId);
|
||||
addToast(`Discarded changes to ${files.length} file(s)`, "success");
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId)]);
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedFiles(new Set());
|
||||
@@ -419,7 +419,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
addToast(`Committed: ${result.hash}`, "success");
|
||||
setCommitMessage("");
|
||||
// Refresh changes and status
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId)]);
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedDiffTarget(null);
|
||||
@@ -443,7 +443,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const result = await createCommit(commitMessage.trim(), projectId);
|
||||
addToast(`Committed: ${result.hash}`, "success");
|
||||
setCommitMessage("");
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId)]);
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedDiffTarget(null);
|
||||
@@ -557,7 +557,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
try {
|
||||
await checkoutBranch(name, projectId);
|
||||
addToast(`Switched to ${name}`, "success");
|
||||
const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId), fetchGitBranches(projectId)]);
|
||||
const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchGitBranches(projectId)]);
|
||||
setStatus(statusData);
|
||||
setBranches(branchesData);
|
||||
} catch (err) {
|
||||
@@ -768,7 +768,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const result = await fetchRemote(undefined, projectId);
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || "Fetch completed", result.fetched ? "success" : "info");
|
||||
const statusData = await fetchGitStatus(projectId);
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Fetch failed", "error");
|
||||
@@ -788,7 +788,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const fallbackMessage = options?.rebase ? "Pull --rebase completed" : "Pull completed";
|
||||
addToast(result.message || fallbackMessage, "success");
|
||||
}
|
||||
const statusData = await fetchGitStatus(projectId);
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Pull failed", "error");
|
||||
@@ -803,7 +803,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const result = await pushBranch(projectId);
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || "Push completed", "success");
|
||||
const statusData = await fetchGitStatus(projectId);
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Push failed", "error");
|
||||
@@ -1022,6 +1022,11 @@ function StatusPanel({
|
||||
<span className="gm-status-value">
|
||||
<GitBranchIcon size={14} />
|
||||
<span>{status.branch}</span>
|
||||
{status.integrationBranch && status.isOnIntegrationBranch === false && (
|
||||
<span className="gm-status-sub" title="Currently on a non-integration branch">
|
||||
{" "}(not on {status.integrationBranch})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gm-status-card">
|
||||
@@ -1030,7 +1035,7 @@ function StatusPanel({
|
||||
<code className="gm-hash">{status.commit}</code>
|
||||
<button
|
||||
className="gm-icon-btn"
|
||||
onClick={() => copyToClipboard(status.commit, "commit hash")}
|
||||
onClick={() => copyToClipboard(status.headSha ?? status.commit, "commit hash")}
|
||||
title="Copy commit hash"
|
||||
>
|
||||
<Copy size={12} />
|
||||
@@ -1052,18 +1057,33 @@ function StatusPanel({
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{status.dirtyDetails && (status.dirtyDetails.staged + status.dirtyDetails.modified + status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && (
|
||||
<span className="gm-status-sub">
|
||||
{status.dirtyDetails.staged > 0 && <span title="Staged">{status.dirtyDetails.staged} staged</span>}
|
||||
{status.dirtyDetails.staged > 0 && (status.dirtyDetails.modified + status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && " · "}
|
||||
{status.dirtyDetails.modified > 0 && <span title="Modified">{status.dirtyDetails.modified} modified</span>}
|
||||
{status.dirtyDetails.modified > 0 && (status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && " · "}
|
||||
{status.dirtyDetails.untracked > 0 && <span title="Untracked">{status.dirtyDetails.untracked} untracked</span>}
|
||||
{status.dirtyDetails.untracked > 0 && status.dirtyDetails.conflicted > 0 && " · "}
|
||||
{status.dirtyDetails.conflicted > 0 && (
|
||||
<span title="Unresolved merge conflicts" className="gm-status-conflict">
|
||||
{status.dirtyDetails.conflicted} conflicted
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="gm-status-card">
|
||||
<span className="gm-status-label">Remote Sync</span>
|
||||
<span className="gm-status-label">vs origin</span>
|
||||
<span className="gm-status-value">
|
||||
{status.ahead > 0 && (
|
||||
<span className="gm-ahead" title={`${status.ahead} commit(s) ahead`}>
|
||||
<span className="gm-ahead" title={`${status.ahead} commit(s) ahead of upstream`}>
|
||||
<ArrowUp size={12} />
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="gm-behind" title={`${status.behind} commit(s) behind`}>
|
||||
<span className="gm-behind" title={`${status.behind} commit(s) behind upstream`}>
|
||||
<ArrowDown size={12} />
|
||||
{status.behind}
|
||||
</span>
|
||||
@@ -1077,6 +1097,138 @@ function StatusPanel({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{status.integrationBranch && (
|
||||
<div className="gm-status-grid">
|
||||
<div className="gm-status-card" data-testid="integration-branch-card">
|
||||
<span className="gm-status-label">Integration branch</span>
|
||||
<span className="gm-status-value">
|
||||
<GitBranchIcon size={14} />
|
||||
<span>{status.integrationBranch}</span>
|
||||
{status.integrationBranchSource && (
|
||||
<span className="gm-status-sub" title={`Resolved from ${status.integrationBranchSource}`}>
|
||||
{" "}({status.integrationBranchSource})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{status.integrationTipSha && (
|
||||
<span className="gm-status-sub">
|
||||
tip <code className="gm-hash">{status.integrationTipSha.slice(0, 8)}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{status.integrationTipSha !== undefined && (status.aheadOfIntegration !== undefined || status.behindIntegration !== undefined) && (
|
||||
<div className="gm-status-card">
|
||||
<span className="gm-status-label">HEAD vs {status.integrationBranch}</span>
|
||||
<span className="gm-status-value">
|
||||
{(status.aheadOfIntegration ?? 0) === 0 && (status.behindIntegration ?? 0) === 0 ? (
|
||||
<span className="gm-in-sync">
|
||||
<CheckCircle size={12} />
|
||||
Aligned
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{(status.aheadOfIntegration ?? 0) > 0 && (
|
||||
<span className="gm-ahead" title={`HEAD has ${status.aheadOfIntegration} commit(s) not on ${status.integrationBranch}`}>
|
||||
<ArrowUp size={12} />
|
||||
{status.aheadOfIntegration}
|
||||
</span>
|
||||
)}
|
||||
{(status.behindIntegration ?? 0) > 0 && (
|
||||
<span className="gm-behind" title={`${status.integrationBranch} has ${status.behindIntegration} commit(s) HEAD doesn't`}>
|
||||
<ArrowDown size={12} />
|
||||
{status.behindIntegration}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status.originIntegrationTipSha !== undefined && (
|
||||
<div className="gm-status-card">
|
||||
<span className="gm-status-label">Local {status.integrationBranch} vs origin</span>
|
||||
<span className="gm-status-value">
|
||||
{status.originIntegrationTipSha === null ? (
|
||||
<span className="gm-status-sub">no origin tracking</span>
|
||||
) : (status.aheadOfOriginIntegration ?? 0) === 0 && (status.behindOriginIntegration ?? 0) === 0 ? (
|
||||
<span className="gm-in-sync">
|
||||
<CheckCircle size={12} />
|
||||
Synced
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{(status.aheadOfOriginIntegration ?? 0) > 0 && (
|
||||
<span className="gm-ahead" title={`Local ${status.integrationBranch} is ${status.aheadOfOriginIntegration} commit(s) ahead of origin/${status.integrationBranch}`}>
|
||||
<ArrowUp size={12} />
|
||||
{status.aheadOfOriginIntegration}
|
||||
</span>
|
||||
)}
|
||||
{(status.behindOriginIntegration ?? 0) > 0 && (
|
||||
<span className="gm-behind" title={`Local ${status.integrationBranch} is ${status.behindOriginIntegration} commit(s) behind origin/${status.integrationBranch}`}>
|
||||
<ArrowDown size={12} />
|
||||
{status.behindOriginIntegration}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(status.stashCount ?? 0) > 0 && (
|
||||
<div className="gm-status-card">
|
||||
<span className="gm-status-label">Stashes</span>
|
||||
<span className="gm-status-value">
|
||||
<Archive size={14} />
|
||||
<span>{status.stashCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status.indexStaleVsHead === true && (
|
||||
<div className="gm-status-warning" data-testid="index-stale-warning" role="alert">
|
||||
<AlertCircle size={14} />
|
||||
<div>
|
||||
<strong>Stale index detected.</strong>{" "}
|
||||
HEAD has advanced (typically because Fusion's merger updated the integration-branch ref)
|
||||
but the index still reflects the previous tip — `git status` will report the new commits
|
||||
inverted as "staged changes." Enable <code>mergeAdvanceAutoSync</code> in Settings to
|
||||
have the merger reconcile automatically, or run <code>git reset --hard HEAD</code> to
|
||||
snap forward manually.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(status.recentMergeAdvances ?? []).length > 0 && (
|
||||
<div className="gm-status-advances" data-testid="recent-merge-advances">
|
||||
<div className="gm-status-advances-header">
|
||||
Recent integration-branch advances
|
||||
<span className="gm-status-sub">
|
||||
{" "}({(status.recentMergeAdvances ?? []).filter((a) => a.needsAction).length} need action)
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
{(status.recentMergeAdvances ?? []).map((advance) => (
|
||||
<li key={`${advance.taskId}-${advance.toSha}`} className={advance.needsAction ? "gm-advance-needs-action" : "gm-advance-handled"}>
|
||||
<code className="gm-hash">{advance.toSha.slice(0, 8)}</code>
|
||||
{" "}
|
||||
<strong>{advance.taskId}</strong>
|
||||
{advance.autoSyncOutcome ? (
|
||||
<span className="gm-status-sub">
|
||||
{" "}auto-sync: <code>{advance.autoSyncOutcome}</code>
|
||||
</span>
|
||||
) : (
|
||||
<span className="gm-status-sub">
|
||||
{" "}auto-sync: <em>off / not run</em>
|
||||
</span>
|
||||
)}
|
||||
<span className="gm-status-sub">
|
||||
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1899,6 +1899,84 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gm-status-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-status-conflict {
|
||||
color: var(--danger, #c0392b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gm-status-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
background: color-mix(in srgb, var(--warning, #f4b400) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f4b400) 40%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gm-status-warning svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--warning, #f4b400);
|
||||
}
|
||||
|
||||
.gm-status-warning code {
|
||||
background: var(--card);
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gm-status-advances {
|
||||
margin-top: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gm-status-advances-header {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-status-advances ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-status-advances li {
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gm-status-advances .gm-advance-needs-action {
|
||||
background: color-mix(in srgb, var(--warning, #f4b400) 8%, transparent);
|
||||
}
|
||||
|
||||
.gm-status-advances .gm-advance-handled {
|
||||
background: color-mix(in srgb, var(--success, #27ae60) 6%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
DirectMergeCommitStrategy,
|
||||
IssueInfo,
|
||||
PrInfo,
|
||||
RunAuditEvent,
|
||||
RunAuditEventInput,
|
||||
Settings,
|
||||
StructuredGhError,
|
||||
@@ -393,6 +394,234 @@ export async function getGitStatus(cwd?: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtendedGitStatus {
|
||||
headSha?: string;
|
||||
integrationBranch?: string;
|
||||
integrationBranchSource?: "settings" | "origin-head" | "fallback";
|
||||
isOnIntegrationBranch?: boolean;
|
||||
integrationTipSha?: string | null;
|
||||
originIntegrationTipSha?: string | null;
|
||||
aheadOfIntegration?: number;
|
||||
behindIntegration?: number;
|
||||
aheadOfOriginIntegration?: number;
|
||||
behindOriginIntegration?: number;
|
||||
dirtyDetails?: {
|
||||
staged: number;
|
||||
modified: number;
|
||||
untracked: number;
|
||||
conflicted: number;
|
||||
sample: string[];
|
||||
};
|
||||
indexStaleVsHead?: boolean;
|
||||
stashCount?: number;
|
||||
recentMergeAdvances?: Array<{
|
||||
taskId: string;
|
||||
fromSha: string | null;
|
||||
toSha: string;
|
||||
advancedAt: string;
|
||||
autoSyncOutcome?: string;
|
||||
needsAction: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function resolveIntegrationBranchForStatus(
|
||||
cwd: string,
|
||||
settings: { integrationBranch?: unknown; baseBranch?: unknown } | null | undefined,
|
||||
): Promise<{ branch: string; source: "settings" | "origin-head" | "fallback" }> {
|
||||
const explicit = typeof settings?.integrationBranch === "string" ? settings.integrationBranch.trim() : "";
|
||||
if (explicit.length > 0) return { branch: explicit, source: "settings" };
|
||||
const legacyBase = typeof settings?.baseBranch === "string" ? (settings.baseBranch as string).trim() : "";
|
||||
if (legacyBase.length > 0) return { branch: legacyBase, source: "settings" };
|
||||
try {
|
||||
const ref = (await runGitCommand(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd, 5_000)).trim();
|
||||
const m = /^refs\/remotes\/origin\/(.+)$/.exec(ref);
|
||||
if (m) return { branch: m[1], source: "origin-head" };
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return { branch: "main", source: "fallback" };
|
||||
}
|
||||
|
||||
async function revParse(cwd: string, ref: string): Promise<string | null> {
|
||||
try {
|
||||
const out = (await runGitCommand(["rev-parse", "--verify", ref], cwd, 5_000)).trim();
|
||||
return out.length > 0 ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function aheadBehind(cwd: string, leftRef: string, rightRef: string): Promise<{ ahead: number; behind: number } | null> {
|
||||
try {
|
||||
const out = (await runGitCommand(["rev-list", "--left-right", "--count", `${leftRef}...${rightRef}`], cwd, 5_000)).trim();
|
||||
const m = out.match(/(\d+)\s+(\d+)/);
|
||||
if (!m) return null;
|
||||
return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeDirtyDetails(cwd: string): Promise<ExtendedGitStatus["dirtyDetails"]> {
|
||||
try {
|
||||
const out = await runGitCommand(["-c", "core.quotePath=false", "status", "--porcelain=v1", "--untracked-files=all"], cwd, 10_000);
|
||||
let staged = 0, modified = 0, untracked = 0, conflicted = 0;
|
||||
const sample: string[] = [];
|
||||
for (const line of out.split("\n")) {
|
||||
if (!line) continue;
|
||||
const x = line[0] ?? " ";
|
||||
const y = line[1] ?? " ";
|
||||
const path = line.slice(3);
|
||||
if (sample.length < 12) sample.push(`${x}${y} ${path}`);
|
||||
if (x === "?" && y === "?") { untracked += 1; continue; }
|
||||
if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) { conflicted += 1; continue; }
|
||||
if (x !== " " && x !== "?") staged += 1;
|
||||
if (y !== " " && y !== "?") modified += 1;
|
||||
}
|
||||
return { staged, modified, untracked, conflicted, sample };
|
||||
} catch {
|
||||
return { staged: 0, modified: 0, untracked: 0, conflicted: 0, sample: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function isIndexStale(cwd: string): Promise<boolean | undefined> {
|
||||
// The FN-INDEX-DESYNC scenario: the merger advanced refs/heads/<branch>
|
||||
// locally so HEAD points at the new tip, but the index still reflects the
|
||||
// previous tip. Detect by diffing the index tree against HEAD's tree —
|
||||
// when the worktree is "clean" relative to HEAD (i.e. all `git status`
|
||||
// changes are really inverted-index artifacts), this returns true.
|
||||
try {
|
||||
const idx = (await runGitCommand(["diff", "--cached", "--name-only"], cwd, 5_000)).trim();
|
||||
if (idx.length === 0) return false;
|
||||
const wt = (await runGitCommand(["diff", "--name-only"], cwd, 5_000)).trim();
|
||||
// Index disagrees with HEAD but worktree matches index — that's the
|
||||
// tell-tale stale-index signal. The merger's auto-sync hook would
|
||||
// normally clear it; surfacing it lets operators see when sync was off
|
||||
// or had to skip.
|
||||
return idx.length > 0 && wt.length === 0;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeStashCount(cwd: string): Promise<number | undefined> {
|
||||
try {
|
||||
const out = (await runGitCommand(["stash", "list", "--format=%H"], cwd, 5_000)).trim();
|
||||
if (out.length === 0) return 0;
|
||||
return out.split("\n").filter((l) => l.length > 0).length;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRecentMergeAdvances(
|
||||
scopedStore: TaskStore & {
|
||||
getRunAuditEvents?: (filters: {
|
||||
taskId?: string;
|
||||
domain?: "database" | "git" | "filesystem" | "sandbox";
|
||||
mutationType?: string;
|
||||
limit?: number;
|
||||
}) => RunAuditEvent[];
|
||||
},
|
||||
worktreePath: string,
|
||||
): Promise<ExtendedGitStatus["recentMergeAdvances"]> {
|
||||
if (typeof scopedStore.getRunAuditEvents !== "function") return [];
|
||||
const advances = scopedStore.getRunAuditEvents({
|
||||
domain: "git",
|
||||
mutationType: "merge:integration-ref-advance",
|
||||
limit: 10,
|
||||
});
|
||||
const autoSyncByTask = new Map<string, string>();
|
||||
for (const ev of scopedStore.getRunAuditEvents({
|
||||
domain: "git",
|
||||
mutationType: "merge:auto-sync",
|
||||
limit: 50,
|
||||
})) {
|
||||
const md = ev.metadata as { worktreePath?: unknown; outcome?: unknown; taskId?: unknown } | undefined;
|
||||
if (!md || typeof md !== "object") continue;
|
||||
if (typeof md.worktreePath !== "string" || md.worktreePath !== worktreePath) continue;
|
||||
if (typeof md.outcome !== "string") continue;
|
||||
const tid = typeof md.taskId === "string" ? md.taskId : (typeof ev.taskId === "string" ? ev.taskId : "");
|
||||
if (tid && !autoSyncByTask.has(tid)) autoSyncByTask.set(tid, md.outcome);
|
||||
}
|
||||
const successOutcomes = new Set(["clean-sync", "synced-with-edits-restored"]);
|
||||
const out: NonNullable<ExtendedGitStatus["recentMergeAdvances"]> = [];
|
||||
for (const ev of advances) {
|
||||
const md = ev.metadata as { fromSha?: unknown; toSha?: unknown; succeeded?: unknown } | undefined;
|
||||
if (!md || typeof md !== "object") continue;
|
||||
if (typeof md.toSha !== "string") continue;
|
||||
if (md.succeeded === false) continue;
|
||||
const tid = typeof ev.taskId === "string" ? ev.taskId : "";
|
||||
if (!tid) continue;
|
||||
const autoSyncOutcome = autoSyncByTask.get(tid);
|
||||
out.push({
|
||||
taskId: tid,
|
||||
fromSha: typeof md.fromSha === "string" ? md.fromSha : null,
|
||||
toSha: md.toSha,
|
||||
advancedAt: ev.timestamp,
|
||||
autoSyncOutcome,
|
||||
needsAction: autoSyncOutcome === undefined || !successOutcomes.has(autoSyncOutcome),
|
||||
});
|
||||
if (out.length >= 5) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function computeExtendedGitStatus(rootDir: string, scopedStore: TaskStore): Promise<ExtendedGitStatus> {
|
||||
const settings = await scopedStore.getSettings().catch(() => null);
|
||||
const { branch: integrationBranch, source: integrationBranchSource } = await resolveIntegrationBranchForStatus(
|
||||
rootDir,
|
||||
settings as { integrationBranch?: unknown; baseBranch?: unknown } | null,
|
||||
);
|
||||
const currentBranch = (await runGitCommand(["branch", "--show-current"], rootDir, 5_000)).trim();
|
||||
const isOnIntegrationBranch = currentBranch === integrationBranch;
|
||||
const headSha = (await revParse(rootDir, "HEAD")) ?? undefined;
|
||||
const integrationTipSha = await revParse(rootDir, `refs/heads/${integrationBranch}`);
|
||||
const originIntegrationTipSha = await revParse(rootDir, `refs/remotes/origin/${integrationBranch}`);
|
||||
|
||||
let aheadOfIntegration: number | undefined;
|
||||
let behindIntegration: number | undefined;
|
||||
if (integrationTipSha && headSha) {
|
||||
const ab = await aheadBehind(rootDir, "HEAD", integrationTipSha);
|
||||
if (ab) { aheadOfIntegration = ab.ahead; behindIntegration = ab.behind; }
|
||||
}
|
||||
let aheadOfOriginIntegration: number | undefined;
|
||||
let behindOriginIntegration: number | undefined;
|
||||
if (originIntegrationTipSha && integrationTipSha) {
|
||||
const ab = await aheadBehind(rootDir, integrationTipSha, originIntegrationTipSha);
|
||||
if (ab) { aheadOfOriginIntegration = ab.ahead; behindOriginIntegration = ab.behind; }
|
||||
}
|
||||
|
||||
const [dirtyDetails, indexStaleVsHead, stashCount, recentMergeAdvances] = await Promise.all([
|
||||
computeDirtyDetails(rootDir),
|
||||
isIndexStale(rootDir),
|
||||
computeStashCount(rootDir),
|
||||
collectRecentMergeAdvances(
|
||||
scopedStore as TaskStore & {
|
||||
getRunAuditEvents?: (filters: { taskId?: string; domain?: "database" | "git" | "filesystem" | "sandbox"; mutationType?: string; limit?: number }) => RunAuditEvent[];
|
||||
},
|
||||
rootDir,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
headSha,
|
||||
integrationBranch,
|
||||
integrationBranchSource,
|
||||
isOnIntegrationBranch,
|
||||
integrationTipSha,
|
||||
originIntegrationTipSha,
|
||||
aheadOfIntegration,
|
||||
behindIntegration,
|
||||
aheadOfOriginIntegration,
|
||||
behindOriginIntegration,
|
||||
dirtyDetails,
|
||||
indexStaleVsHead,
|
||||
stashCount,
|
||||
recentMergeAdvances,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
@@ -2169,9 +2398,14 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/status
|
||||
* GET /api/git/status[?extended=1]
|
||||
* Returns current git status: branch, commit hash, dirty state, ahead/behind counts.
|
||||
* Response: { branch: string, commit: string, isDirty: boolean, ahead: number, behind: number }
|
||||
* When `extended=1` is set, also returns integration-branch resolution, ahead/
|
||||
* behind vs both local and origin integration tip, dirty breakdown, stash count,
|
||||
* index-stale detection (the FN-INDEX-DESYNC scenario the auto-sync hook
|
||||
* fixes), and the most-recent merger ref-advance audit events for this
|
||||
* worktree (so operators can see what needs to be pulled even if the
|
||||
* Merge Advance Notice banner was dismissed).
|
||||
*/
|
||||
router.get("/git/status", async (req, res) => {
|
||||
try {
|
||||
@@ -2184,7 +2418,12 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
if (!status) {
|
||||
throw internalError("Failed to get git status");
|
||||
}
|
||||
res.json(status);
|
||||
if (req.query.extended !== "1" && req.query.extended !== "true") {
|
||||
res.json(status);
|
||||
return;
|
||||
}
|
||||
const extended = await computeExtendedGitStatus(rootDir, scopedStore);
|
||||
res.json({ ...status, ...extended });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user