feat(FN-3724): add split-button for git pull with rebase option
The merge adds a pull split-button in the GitManager modal that lets users choose between a regular `git pull` and a `git pull --rebase`, with styling and test coverage for the menu interactions and API payload. It also fixes plugin toggle rendering (FN-3723) and adds a release note for plugin insta Fusion-Task-Id: FN-3724
This commit is contained in:
5
.changeset/FN-3724-pull-rebase.md
Normal file
5
.changeset/FN-3724-pull-rebase.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a split Pull action in the Git Manager Remotes panel with a dropdown option to run `Pull --rebase`.
|
||||||
@@ -1159,7 +1159,7 @@ Git dashboard routes are registered in `register-git-github.ts`.
|
|||||||
| POST | `/api/git/branches/:name/checkout` | Checkout an existing branch. |
|
| POST | `/api/git/branches/:name/checkout` | Checkout an existing branch. |
|
||||||
| DELETE | `/api/git/branches/:name` | Delete a branch (`?force=true` allows deleting unmerged branches). |
|
| DELETE | `/api/git/branches/:name` | Delete a branch (`?force=true` allows deleting unmerged branches). |
|
||||||
| POST | `/api/git/fetch` | Fetch from a remote (`remote` defaults to `origin`). |
|
| POST | `/api/git/fetch` | Fetch from a remote (`remote` defaults to `origin`). |
|
||||||
| POST | `/api/git/pull` | Pull the current branch and return structured conflict metadata on merge conflicts. |
|
| POST | `/api/git/pull` | Pull the current branch (`rebase` boolean optional) and return structured conflict metadata on merge/rebase conflicts. |
|
||||||
| POST | `/api/git/push` | Push the current branch. |
|
| POST | `/api/git/push` | Push the current branch. |
|
||||||
| GET | `/api/git/stashes` | List stash entries. |
|
| GET | `/api/git/stashes` | List stash entries. |
|
||||||
| GET | `/api/git/stashes/:index/diff` | Return stash stat + patch for a validated stash index (404 when missing). |
|
| GET | `/api/git/stashes/:index/diff` | Return stash stat + patch for a validated stash index (404 when missing). |
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ describe("Git Management API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("pullBranch", () => {
|
describe("pullBranch", () => {
|
||||||
it("sends POST to pull", async () => {
|
it("sends POST to pull with default rebase false", async () => {
|
||||||
const result = { success: true, message: "Pulled 2 commits" };
|
const result = { success: true, message: "Pulled 2 commits" };
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||||
|
|
||||||
@@ -448,6 +448,20 @@ describe("Git Management API", () => {
|
|||||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rebase: false }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends rebase true when requested", async () => {
|
||||||
|
const result = { success: true, message: "Rebased and pulled" };
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||||
|
|
||||||
|
await pullBranch({ rebase: true });
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rebase: true }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2358,9 +2358,18 @@ export function fetchRemote(remote?: string, projectId?: string): Promise<GitFet
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pull current branch */
|
/** Pull current branch */
|
||||||
export function pullBranch(projectId?: string): Promise<GitPullResult> {
|
export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
|
||||||
return api<GitPullResult>(withProjectId("/git/pull", projectId), {
|
export function pullBranch(projectId?: string): Promise<GitPullResult>;
|
||||||
|
export function pullBranch(
|
||||||
|
optionsOrProjectId?: { rebase?: boolean } | string,
|
||||||
|
projectId?: string,
|
||||||
|
): Promise<GitPullResult> {
|
||||||
|
const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
|
||||||
|
const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
|
||||||
|
|
||||||
|
return api<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rebase: options?.rebase ?? false }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -777,15 +777,16 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
}
|
}
|
||||||
}, [addToast, projectId]);
|
}, [addToast, projectId]);
|
||||||
|
|
||||||
const handlePull = useCallback(async () => {
|
const handlePull = useCallback(async (options?: { rebase?: boolean }) => {
|
||||||
setRemoteLoading("pull");
|
setRemoteLoading("pull");
|
||||||
try {
|
try {
|
||||||
const result = await pullBranch(projectId);
|
const result = await pullBranch(options, projectId);
|
||||||
setLastRemoteResult(result);
|
setLastRemoteResult(result);
|
||||||
if (result.conflict) {
|
if (result.conflict) {
|
||||||
addToast("Merge conflict detected. Resolve manually.", "error");
|
addToast("Merge conflict detected. Resolve manually.", "error");
|
||||||
} else {
|
} else {
|
||||||
addToast(result.message || "Pull completed", "success");
|
const fallbackMessage = options?.rebase ? "Pull --rebase completed" : "Pull completed";
|
||||||
|
addToast(result.message || fallbackMessage, "success");
|
||||||
}
|
}
|
||||||
const statusData = await fetchGitStatus(projectId);
|
const statusData = await fetchGitStatus(projectId);
|
||||||
setStatus(statusData);
|
setStatus(statusData);
|
||||||
@@ -1909,7 +1910,7 @@ function RemotesPanel({
|
|||||||
remoteLoading: string | null;
|
remoteLoading: string | null;
|
||||||
lastRemoteResult: GitFetchResult | GitPullResult | GitPushResult | null;
|
lastRemoteResult: GitFetchResult | GitPullResult | GitPushResult | null;
|
||||||
onFetch: () => void;
|
onFetch: () => void;
|
||||||
onPull: () => void;
|
onPull: (options?: { rebase?: boolean }) => void;
|
||||||
onPush: () => void;
|
onPush: () => void;
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -1933,6 +1934,8 @@ function RemotesPanel({
|
|||||||
const [editUrlValue, setEditUrlValue] = useState("");
|
const [editUrlValue, setEditUrlValue] = useState("");
|
||||||
const [editNameValue, setEditNameValue] = useState("");
|
const [editNameValue, setEditNameValue] = useState("");
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [pullMenuOpen, setPullMenuOpen] = useState(false);
|
||||||
|
const pullSplitRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Ahead commits (local commits to push)
|
// Ahead commits (local commits to push)
|
||||||
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
|
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
|
||||||
@@ -1948,6 +1951,38 @@ function RemotesPanel({
|
|||||||
// Derived state for selected remote
|
// Derived state for selected remote
|
||||||
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
|
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteLoading !== null || loading) {
|
||||||
|
setPullMenuOpen(false);
|
||||||
|
}
|
||||||
|
}, [remoteLoading, loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pullMenuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (!pullSplitRef.current?.contains(event.target as Node)) {
|
||||||
|
setPullMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setPullMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleDocumentClick);
|
||||||
|
document.addEventListener("keydown", handleDocumentKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleDocumentClick);
|
||||||
|
document.removeEventListener("keydown", handleDocumentKeyDown);
|
||||||
|
};
|
||||||
|
}, [pullMenuOpen]);
|
||||||
|
|
||||||
// Inline commit diff expansion (one per list context)
|
// Inline commit diff expansion (one per list context)
|
||||||
const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null);
|
const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null);
|
||||||
const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||||
@@ -2317,18 +2352,57 @@ function RemotesPanel({
|
|||||||
)}
|
)}
|
||||||
Fetch
|
Fetch
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="gm-pull-split" ref={pullSplitRef}>
|
||||||
className="btn btn-primary"
|
<button
|
||||||
onClick={onPull}
|
className="btn btn-primary gm-pull-split-main"
|
||||||
disabled={remoteLoading !== null || loading}
|
onClick={() => {
|
||||||
>
|
setPullMenuOpen(false);
|
||||||
{remoteLoading === "pull" ? (
|
onPull({ rebase: false });
|
||||||
<Loader2 size={14} className="spin" />
|
}}
|
||||||
) : (
|
disabled={remoteLoading !== null || loading}
|
||||||
<GitPullRequest size={14} />
|
>
|
||||||
)}
|
{remoteLoading === "pull" ? (
|
||||||
Pull
|
<Loader2 size={14} className="spin" />
|
||||||
</button>
|
) : (
|
||||||
|
<GitPullRequest size={14} />
|
||||||
|
)}
|
||||||
|
Pull
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-icon gm-pull-split-toggle"
|
||||||
|
onClick={() => setPullMenuOpen((open) => !open)}
|
||||||
|
disabled={remoteLoading !== null || loading}
|
||||||
|
aria-label="Pull options"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={pullMenuOpen}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{pullMenuOpen ? (
|
||||||
|
<div className="gm-pull-menu" role="menu" aria-label="Pull options menu">
|
||||||
|
<button
|
||||||
|
className="gm-pull-menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setPullMenuOpen(false);
|
||||||
|
onPull({ rebase: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pull
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="gm-pull-menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setPullMenuOpen(false);
|
||||||
|
onPull({ rebase: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pull --rebase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={onPush}
|
onClick={onPush}
|
||||||
|
|||||||
@@ -2982,6 +2982,66 @@
|
|||||||
flex: 1 1 calc(var(--space-2xl) * 5);
|
flex: 1 1 calc(var(--space-2xl) * 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gm-pull-split {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 calc(var(--space-2xl) * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-split .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-split-main {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-split-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-left: 0;
|
||||||
|
padding-inline: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
z-index: 4;
|
||||||
|
min-width: calc(var(--space-2xl) * 5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--surface) 80%, var(--card));
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-menu-item {
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-menu-item:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-menu-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.gm-remote-result {
|
.gm-remote-result {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -3584,6 +3644,16 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gm-pull-split {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-pull-menu {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.gm-remotes-layout {
|
.gm-remotes-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1363,20 +1363,62 @@ describe("GitManagerModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls pullBranch when Pull button clicked", async () => {
|
it("calls pullBranch with rebase false when Pull button clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||||
|
|
||||||
// Wait for sync card and scope button search within it
|
|
||||||
const syncCard = await screen.findByTestId("remote-sync-card");
|
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||||
const pullButton = within(syncCard).getByRole("button", { name: /pull/i });
|
const pullButton = within(syncCard).getByRole("button", { name: /^pull$/i });
|
||||||
await user.click(pullButton);
|
await user.click(pullButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(pullBranch).toHaveBeenCalled();
|
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls pullBranch with rebase true from pull options menu", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||||
|
|
||||||
|
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||||
|
await user.click(within(syncCard).getByRole("button", { name: /pull options/i }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: /pull --rebase/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes pull options menu on outside click and Escape", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||||
|
|
||||||
|
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||||
|
const pullOptionsButton = within(syncCard).getByRole("button", { name: /pull options/i });
|
||||||
|
|
||||||
|
await user.click(pullOptionsButton);
|
||||||
|
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(document.body);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(pullOptionsButton);
|
||||||
|
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2114,10 +2156,10 @@ describe("GitManagerModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const syncCard = screen.getByTestId("remote-sync-card");
|
const syncCard = screen.getByTestId("remote-sync-card");
|
||||||
await user.click(within(syncCard).getByRole("button", { name: /pull/i }));
|
await user.click(within(syncCard).getByRole("button", { name: /^pull$/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(pullBranch).toHaveBeenCalled();
|
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
|
||||||
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
|
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
|
||||||
expect(screen.getByText("Remote commit after pull")).toBeInTheDocument();
|
expect(screen.getByText("Remote commit after pull")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument();
|
expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument();
|
||||||
@@ -2126,13 +2168,18 @@ describe("GitManagerModal", () => {
|
|||||||
|
|
||||||
it("refreshes recent remote commits after push without reopening modal", async () => {
|
it("refreshes recent remote commits after push without reopening modal", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
(fetchRemoteCommits as any)
|
let remoteCommitsCallCount = 0;
|
||||||
.mockResolvedValueOnce([
|
(fetchRemoteCommits as any).mockImplementation(() => {
|
||||||
{ hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
remoteCommitsCallCount += 1;
|
||||||
])
|
if (remoteCommitsCallCount <= 1) {
|
||||||
.mockResolvedValueOnce([
|
return Promise.resolve([
|
||||||
|
{ hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([
|
||||||
{ hash: "rc11", shortHash: "rc11", message: "Remote commit after push", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
|
{ hash: "rc11", shortHash: "rc11", message: "Remote commit after push", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||||
|
|||||||
@@ -464,16 +464,21 @@ export interface GitPullResult {
|
|||||||
conflict?: boolean;
|
conflict?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pullGitBranch(cwd?: string): Promise<GitPullResult> {
|
export async function pullGitBranch(cwd?: string, options?: { rebase?: boolean }): Promise<GitPullResult> {
|
||||||
|
const rebase = options?.rebase === true;
|
||||||
try {
|
try {
|
||||||
const output = await runGitCommand(["pull"], cwd, 30000);
|
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull"], cwd, 30000);
|
||||||
return { success: true, message: output.trim() };
|
const message = output.trim();
|
||||||
|
if (message) {
|
||||||
|
return { success: true, message };
|
||||||
|
}
|
||||||
|
return { success: true, message: rebase ? "Pull completed (rebase)" : "Pull completed" };
|
||||||
} 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")) {
|
if (message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply")) {
|
||||||
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
|
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
|
||||||
}
|
}
|
||||||
throw new Error(message || "Pull failed");
|
throw new Error(message || "Pull failed");
|
||||||
@@ -1653,7 +1658,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
if (!(await isGitRepo(rootDir))) {
|
if (!(await isGitRepo(rootDir))) {
|
||||||
throw badRequest("Not a git repository");
|
throw badRequest("Not a git repository");
|
||||||
}
|
}
|
||||||
const result = await pullGitBranch(rootDir);
|
const { rebase } = req.body ?? {};
|
||||||
|
if (rebase !== undefined && typeof rebase !== "boolean") {
|
||||||
|
throw badRequest("rebase must be a boolean");
|
||||||
|
}
|
||||||
|
const result = await pullGitBranch(rootDir, { rebase: rebase === true });
|
||||||
if (result.conflict) {
|
if (result.conflict) {
|
||||||
throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", {
|
throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
Reference in New Issue
Block a user