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:
@@ -2607,6 +2607,9 @@ export interface GitPullResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
conflict?: boolean;
|
||||
autostashed?: boolean;
|
||||
stashReapplied?: boolean;
|
||||
stashConflict?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a push operation */
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpd
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } 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 { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
|
||||
import * as planningModule from "../planning.js";
|
||||
@@ -297,6 +298,34 @@ type GitTestRepo = {
|
||||
|
||||
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 {
|
||||
if (sharedGitTestRepo) {
|
||||
return sharedGitTestRepo;
|
||||
@@ -874,6 +903,54 @@ describe("Git Management endpoints", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -692,24 +692,130 @@ export interface GitPullResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
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> {
|
||||
const rebase = options?.rebase === true;
|
||||
const autostash = await createPullAutostash(cwd);
|
||||
try {
|
||||
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull"], cwd, 30000);
|
||||
const message = output.trim();
|
||||
if (message) {
|
||||
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull", "--ff-only"], cwd, 30_000);
|
||||
const message = output.trim() || (rebase ? "Pull completed (rebase)" : "Pull completed");
|
||||
|
||||
if (!autostash) {
|
||||
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) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const message = getCommandErrorMessage(err);
|
||||
if (message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply")) {
|
||||
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
|
||||
if (isGitConflictMessage(message)) {
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user