fix(dashboard): autostash dirty working tree across git pull and surface reapply conflicts
The dashboard's git pull endpoint failed outright when the working tree had local edits or untracked files. It now stashes (including untracked) under a fusion-dashboard-pull-autostash label, performs the pull, and reapplies the stash. If reapplying conflicts, the stash is preserved and GitPullResult surfaces autostashed/stashReapplied/stashConflict plus a message pointing at the stash label so the user can resolve from the Stashes view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-dashboard-pull-autostash.md
Normal file
5
.changeset/fix-dashboard-pull-autostash.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Dashboard git pull now autostashes dirty local changes (including untracked files) before pulling and reapplies them on success. If reapplying conflicts, the stash is preserved and the operation reports `stashConflict` with the stash label so the user can resolve later from the Stashes view. Previously a dirty working tree caused the pull to fail outright with no recovery path.
|
||||||
@@ -2607,6 +2607,9 @@ export interface GitPullResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
conflict?: boolean;
|
conflict?: boolean;
|
||||||
|
autostashed?: boolean;
|
||||||
|
stashReapplied?: boolean;
|
||||||
|
stashConflict?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of a push operation */
|
/** Result of a push operation */
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpd
|
|||||||
import type { TaskDetail } from "@fusion/core";
|
import type { TaskDetail } from "@fusion/core";
|
||||||
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
|
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
|
||||||
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
|
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
|
||||||
|
import { pullGitBranch } from "../routes/register-git-github.js";
|
||||||
import * as agentGenerationModule from "../agent-generation.js";
|
import * as agentGenerationModule from "../agent-generation.js";
|
||||||
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
|
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
|
||||||
import * as planningModule from "../planning.js";
|
import * as planningModule from "../planning.js";
|
||||||
@@ -297,6 +298,34 @@ type GitTestRepo = {
|
|||||||
|
|
||||||
let sharedGitTestRepo: GitTestRepo | null = null;
|
let sharedGitTestRepo: GitTestRepo | null = null;
|
||||||
|
|
||||||
|
function git(cwd: string, ...args: string[]): string {
|
||||||
|
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", stdio: "pipe" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIsolatedPullRepo() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "kb-dashboard-pull-"));
|
||||||
|
const remoteDir = join(root, "remote.git");
|
||||||
|
const repoDir = join(root, "repo");
|
||||||
|
const upstreamDir = join(root, "upstream");
|
||||||
|
|
||||||
|
mkdirSync(repoDir, { recursive: true });
|
||||||
|
execFileSync("git", ["init", "--bare", "--initial-branch=main", remoteDir], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
||||||
|
writeFileSync(join(repoDir, "README.md"), "base\n");
|
||||||
|
writeFileSync(join(repoDir, "LOCAL.md"), "local-base\n");
|
||||||
|
execFileSync("git", ["-C", repoDir, "add", "README.md", "LOCAL.md"], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", repoDir, "commit", "-m", "Initial commit"], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", repoDir, "remote", "add", "origin", remoteDir], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", repoDir, "push", "-u", "origin", "HEAD"], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["clone", remoteDir, upstreamDir], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", upstreamDir, "config", "user.email", "kb-upstream@example.com"], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["-C", upstreamDir, "config", "user.name", "KB Upstream"], { stdio: "pipe" });
|
||||||
|
|
||||||
|
return { root, remoteDir, repoDir, upstreamDir };
|
||||||
|
}
|
||||||
|
|
||||||
function getSharedGitTestRepo(): GitTestRepo {
|
function getSharedGitTestRepo(): GitTestRepo {
|
||||||
if (sharedGitTestRepo) {
|
if (sharedGitTestRepo) {
|
||||||
return sharedGitTestRepo;
|
return sharedGitTestRepo;
|
||||||
@@ -874,6 +903,54 @@ describe("Git Management endpoints", () => {
|
|||||||
expect(res.body).toHaveProperty("message");
|
expect(res.body).toHaveProperty("message");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("autostashes dirty local changes, fast-forwards, and reapplies them", async () => {
|
||||||
|
const repo = createIsolatedPullRepo();
|
||||||
|
try {
|
||||||
|
writeFileSync(join(repo.upstreamDir, "README.md"), "base\nremote\n");
|
||||||
|
git(repo.upstreamDir, "add", "README.md");
|
||||||
|
git(repo.upstreamDir, "commit", "-m", "Remote update");
|
||||||
|
git(repo.upstreamDir, "push", "origin", "HEAD");
|
||||||
|
|
||||||
|
writeFileSync(join(repo.repoDir, "LOCAL.md"), "local-base\nlocal-edit\n");
|
||||||
|
writeFileSync(join(repo.repoDir, "local.txt"), "untracked\n");
|
||||||
|
|
||||||
|
const result = await pullGitBranch(repo.repoDir);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.autostashed).toBe(true);
|
||||||
|
expect(result.stashReapplied).toBe(true);
|
||||||
|
expect(readFileSync(join(repo.repoDir, "README.md"), "utf-8")).toContain("remote\n");
|
||||||
|
expect(readFileSync(join(repo.repoDir, "LOCAL.md"), "utf-8")).toContain("local-edit\n");
|
||||||
|
expect(readFileSync(join(repo.repoDir, "local.txt"), "utf-8")).toBe("untracked\n");
|
||||||
|
expect(git(repo.repoDir, "stash", "list")).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(repo.root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the stash when reapplying local edits conflicts after pull", async () => {
|
||||||
|
const repo = createIsolatedPullRepo();
|
||||||
|
try {
|
||||||
|
writeFileSync(join(repo.upstreamDir, "README.md"), "remote-only\n");
|
||||||
|
git(repo.upstreamDir, "add", "README.md");
|
||||||
|
git(repo.upstreamDir, "commit", "-m", "Remote conflict update");
|
||||||
|
git(repo.upstreamDir, "push", "origin", "HEAD");
|
||||||
|
|
||||||
|
writeFileSync(join(repo.repoDir, "README.md"), "local-only\n");
|
||||||
|
|
||||||
|
const result = await pullGitBranch(repo.repoDir);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.conflict).toBe(true);
|
||||||
|
expect(result.autostashed).toBe(true);
|
||||||
|
expect(result.stashConflict).toBe(true);
|
||||||
|
expect(result.message).toContain("reapplying your local edits conflicted");
|
||||||
|
expect(git(repo.repoDir, "stash", "list")).toContain("fusion-dashboard-pull-autostash");
|
||||||
|
} finally {
|
||||||
|
rmSync(repo.root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /git/push", () => {
|
describe("POST /git/push", () => {
|
||||||
|
|||||||
@@ -692,24 +692,130 @@ export interface GitPullResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
conflict?: boolean;
|
conflict?: boolean;
|
||||||
|
autostashed?: boolean;
|
||||||
|
stashReapplied?: boolean;
|
||||||
|
stashConflict?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullAutostashHandle {
|
||||||
|
sha: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitConflictMessage(message: string): boolean {
|
||||||
|
return message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasLocalChangesForPull(cwd?: string): Promise<boolean> {
|
||||||
|
const output = await runGitCommand(["status", "--porcelain=v1", "--untracked-files=all"], cwd, 10_000);
|
||||||
|
return output.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findStashRefBySha(sha: string, cwd?: string): Promise<string | null> {
|
||||||
|
const output = await runGitCommand(["stash", "list", '--format=%H|%gd'], cwd, 5_000);
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const [entrySha, ref] = line.trim().split("|");
|
||||||
|
if (entrySha === sha && ref) {
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropStashBySha(sha: string, cwd?: string): Promise<void> {
|
||||||
|
const ref = await findStashRefBySha(sha, cwd);
|
||||||
|
if (!ref) return;
|
||||||
|
await runGitCommand(["stash", "drop", ref], cwd, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPullAutostash(cwd?: string): Promise<PullAutostashHandle | null> {
|
||||||
|
if (!(await hasLocalChangesForPull(cwd))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = `fusion-dashboard-pull-autostash:${Date.now()}`;
|
||||||
|
const output = await runGitCommand(["stash", "push", "-u", "-m", label], cwd, 15_000);
|
||||||
|
if (output.includes("No local changes to save")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sha = (await runGitCommand(["rev-parse", "stash@{0}"], cwd, 5_000)).trim();
|
||||||
|
if (!sha) {
|
||||||
|
throw new Error("Pull autostash failed: could not resolve created stash");
|
||||||
|
}
|
||||||
|
return { sha, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reapplyPullAutostash(
|
||||||
|
handle: PullAutostashHandle,
|
||||||
|
cwd?: string,
|
||||||
|
): Promise<{ applied: boolean; conflict: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
await runGitCommand(["stash", "apply", handle.sha], cwd, 20_000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getCommandErrorMessage(err);
|
||||||
|
if (isGitConflictMessage(message) || message.includes("Command failed: git stash apply")) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
conflict: true,
|
||||||
|
message:
|
||||||
|
`Pulled latest changes, but reapplying your local edits conflicted. ` +
|
||||||
|
`Your work was preserved in stash ${handle.sha.slice(0, 7)} (${handle.label}). ` +
|
||||||
|
`Resolve the conflicts in the working tree or reapply later from the Stashes view.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dropStashBySha(handle.sha, cwd).catch(() => undefined);
|
||||||
|
return { applied: true, conflict: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pullGitBranch(cwd?: string, options?: { rebase?: boolean }): Promise<GitPullResult> {
|
export async function pullGitBranch(cwd?: string, options?: { rebase?: boolean }): Promise<GitPullResult> {
|
||||||
const rebase = options?.rebase === true;
|
const rebase = options?.rebase === true;
|
||||||
|
const autostash = await createPullAutostash(cwd);
|
||||||
try {
|
try {
|
||||||
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull"], cwd, 30000);
|
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull", "--ff-only"], cwd, 30_000);
|
||||||
const message = output.trim();
|
const message = output.trim() || (rebase ? "Pull completed (rebase)" : "Pull completed");
|
||||||
if (message) {
|
|
||||||
|
if (!autostash) {
|
||||||
return { success: true, message };
|
return { success: true, message };
|
||||||
}
|
}
|
||||||
return { success: true, message: rebase ? "Pull completed (rebase)" : "Pull completed" };
|
|
||||||
|
const reapply = await reapplyPullAutostash(autostash, cwd);
|
||||||
|
if (reapply.conflict) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
conflict: true,
|
||||||
|
message: reapply.message ?? "Pulled latest changes, but reapplying local edits conflicted.",
|
||||||
|
autostashed: true,
|
||||||
|
stashConflict: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${message}\n\nRestored your local changes from an automatic pre-pull stash.`,
|
||||||
|
autostashed: true,
|
||||||
|
stashReapplied: true,
|
||||||
|
};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const message = getCommandErrorMessage(err);
|
const message = getCommandErrorMessage(err);
|
||||||
if (message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply")) {
|
if (isGitConflictMessage(message)) {
|
||||||
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
|
const preservedMessage = autostash
|
||||||
|
? `Merge conflict detected during pull. Your local edits were preserved in stash ${autostash.sha.slice(0, 7)} (${autostash.label}). Resolve the pull conflict first, then reapply from the Stashes view.`
|
||||||
|
: "Merge conflict detected. Resolve manually.";
|
||||||
|
return { success: false, message: preservedMessage, conflict: true, autostashed: Boolean(autostash) };
|
||||||
|
}
|
||||||
|
if (autostash) {
|
||||||
|
const restored = await reapplyPullAutostash(autostash, cwd).catch(() => null);
|
||||||
|
if (restored?.applied) {
|
||||||
|
throw new Error(`${message || "Pull failed"}\n\nYour local changes were restored from the automatic pre-pull stash.`);
|
||||||
|
}
|
||||||
|
if (restored?.conflict) {
|
||||||
|
throw new Error(`${message || "Pull failed"}\n\n${restored.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error(message || "Pull failed");
|
throw new Error(message || "Pull failed");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user