Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1823,20 +1823,58 @@ it.layer(TestLayer)("git integration", (it) => {
yield* git(clone, ["push", "origin", initialBranch]);

const core = yield* GitCore;
const pulled = yield* core.pullCurrentBranch(source);
const pulled = yield* core.syncCurrentBranch(source);
expect(pulled.status).toBe("pulled");
expect(pulled.strategy).toBe("pull");
expect((yield* core.statusDetails(source)).behindCount).toBe(0);

const skipped = yield* core.pullCurrentBranch(source);
const skipped = yield* core.syncCurrentBranch(source);
expect(skipped.status).toBe("skipped_up_to_date");
}),
);

it.effect("rebases diverged branches and reports conflicts", () =>
Effect.gen(function* () {
const remote = yield* makeTmpDir();
const source = yield* makeTmpDir();
const clone = yield* makeTmpDir();
yield* git(remote, ["init", "--bare"]);

yield* initRepoWithCommit(source);
const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
(branch) => branch.current,
)!.name;
yield* git(source, ["remote", "add", "origin", remote]);
yield* git(source, ["push", "-u", "origin", initialBranch]);

yield* writeTextFile(path.join(source, "README.md"), "local change\n");
yield* git(source, ["add", "README.md"]);
yield* git(source, ["commit", "-m", "local update"]);

yield* git(clone, ["clone", remote, "."]);
yield* git(clone, ["config", "user.email", "test@test.com"]);
yield* git(clone, ["config", "user.name", "Test"]);
yield* writeTextFile(path.join(clone, "README.md"), "remote change\n");
yield* git(clone, ["add", "README.md"]);
yield* git(clone, ["commit", "-m", "remote update"]);
yield* git(clone, ["push", "origin", initialBranch]);

const core = yield* GitCore;
const result = yield* core.syncCurrentBranch(source);

expect(result.status).toBe("conflicted");
expect(result.strategy).toBe("rebase");
expect(result.hasConflicts).toBe(true);
expect(result.conflictedFiles).toContain("README.md");
expect((yield* core.statusDetails(source)).hasConflicts).toBe(true);
}),
);

it.effect("top-level pullGitBranch rejects when no upstream exists", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp));
const result = yield* Effect.result((yield* GitCore).syncCurrentBranch(tmp));
expect(result._tag).toBe("Failure");
if (result._tag === "Failure") {
expect(result.failure.message.toLowerCase()).toContain("no upstream");
Expand Down
63 changes: 54 additions & 9 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1421,48 +1421,93 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
};
});

const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) =>
const syncCurrentBranch: GitCoreShape["syncCurrentBranch"] = (cwd) =>
Effect.gen(function* () {
const details = yield* statusDetails(cwd);
const branch = details.branch;
if (!branch) {
return yield* createGitCommandError(
"GitCore.pullCurrentBranch",
"GitCore.syncCurrentBranch",
cwd,
["pull", "--ff-only"],
"Cannot pull from detached HEAD.",
"Cannot sync from detached HEAD.",
);
}
if (!details.hasUpstream) {
return yield* createGitCommandError(
"GitCore.pullCurrentBranch",
"GitCore.syncCurrentBranch",
cwd,
["pull", "--ff-only"],
"Current branch has no upstream configured. Push with upstream first.",
);
}
if (details.hasConflicts) {
return yield* createGitCommandError(
"GitCore.syncCurrentBranch",
cwd,
["status", "--porcelain=v2", "--branch"],
"Resolve existing merge conflicts before syncing this branch.",
);
}
const beforeSha = yield* runGitStdout(
"GitCore.pullCurrentBranch.beforeSha",
"GitCore.syncCurrentBranch.beforeSha",
cwd,
["rev-parse", "HEAD"],
true,
).pipe(Effect.map((stdout) => stdout.trim()));
yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
const strategy = details.aheadCount > 0 && details.behindCount > 0 ? "rebase" : "pull";
const args =
strategy === "rebase"
? (["pull", "--rebase"] as const)
: (["pull", "--ff-only"] as const);
const syncResult = yield* executeGit("GitCore.syncCurrentBranch.pull", cwd, args, {
timeoutMs: 30_000,
allowNonZeroExit: true,
fallbackErrorMessage: "git pull failed",
});
if (syncResult.code !== 0) {
const refreshed = yield* statusDetails(cwd).pipe(
Effect.catch(() => Effect.succeed(details)),
);
if (strategy === "rebase" && refreshed.hasConflicts) {
return {
status: "conflicted" as const,
strategy,
cwd,
branch,
upstreamBranch: refreshed.upstreamRef,
hasConflicts: true,
conflictedFiles: refreshed.conflictedFiles,
};
}
return yield* createGitCommandError(
"GitCore.syncCurrentBranch",
cwd,
args,
syncResult.stderr.trim().length > 0 ? syncResult.stderr.trim() : "git pull failed",
);
}
const afterSha = yield* runGitStdout(
"GitCore.pullCurrentBranch.afterSha",
"GitCore.syncCurrentBranch.afterSha",
cwd,
["rev-parse", "HEAD"],
true,
).pipe(Effect.map((stdout) => stdout.trim()));

const refreshed = yield* statusDetails(cwd);
return {
status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
status:
beforeSha.length > 0 && beforeSha === afterSha
? ("skipped_up_to_date" as const)
: strategy === "rebase"
? ("rebased" as const)
: ("pulled" as const),
strategy,
cwd,
branch,
upstreamBranch: refreshed.upstreamRef,
hasConflicts: refreshed.hasConflicts,
conflictedFiles: refreshed.conflictedFiles,
};
});

Expand Down Expand Up @@ -1994,7 +2039,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
prepareCommitContext,
commit,
pushCurrentBranch,
pullCurrentBranch,
syncCurrentBranch,
readRangeContext,
readConfigValue,
listBranches,
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/git/Services/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,10 @@ export interface GitCoreShape {
) => Effect.Effect<GitListBranchesResult, GitCommandError>;

/**
* Pull current branch from upstream using fast-forward only.
* Synchronize the current branch with its upstream using fast-forward pull
* when cleanly behind and rebase when the branch has diverged.
*/
readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>;
readonly syncCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>;

/**
* Create a worktree and branch from a base branch.
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ describe("WebSocket Server", () => {
providerHealth?: ProviderHealthShape;
open?: OpenShape;
gitManager?: GitManagerShape;
gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "syncCurrentBranch">;
terminalManager?: TerminalManagerShape;
} = {},
): Promise<Http.Server> {
Expand Down Expand Up @@ -1805,10 +1805,10 @@ describe("WebSocket Server", () => {
}),
);
const initRepo = vi.fn(() => Effect.void);
const pullCurrentBranch = vi.fn(() =>
const syncCurrentBranch = vi.fn(() =>
Effect.fail(
new GitCommandError({
operation: "GitCore.test.pullCurrentBranch",
operation: "GitCore.test.syncCurrentBranch",
detail: "No upstream configured",
command: "git pull",
cwd: "/repo/path",
Expand All @@ -1822,7 +1822,7 @@ describe("WebSocket Server", () => {
gitCore: {
listBranches,
initRepo,
pullCurrentBranch,
syncCurrentBranch,
},
});
const addr = server.address();
Expand All @@ -1843,7 +1843,7 @@ describe("WebSocket Server", () => {
const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" });
expect(pullResponse.result).toBeUndefined();
expect(pullResponse.error?.message).toContain("No upstream configured");
expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
expect(syncCurrentBranch).toHaveBeenCalledWith("/repo/path");
});

it("supports git.status over websocket", async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
const snapshot = yield* projectionReadModelQuery.getSnapshot();
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
return yield* git
.pullCurrentBranch(body.cwd)
.syncCurrentBranch(body.cwd)
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
}

Expand Down
32 changes: 24 additions & 8 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,10 @@ export default function BranchToolbar({
const queryClient = useQueryClient();
const gitCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
const gitStatus = useQuery(gitStatusQueryOptions(gitCwd));
const aheadCount = gitStatus.data?.aheadCount ?? 0;
const behindCount = gitStatus.data?.behindCount ?? 0;
const isBehindUpstream = behindCount > 0 && !hasServerThread;
const isDiverged = aheadCount > 0 && behindCount > 0;
const needsSync = behindCount > 0 && !hasServerThread;
const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));

// Force a fresh git-status fetch when a draft thread mounts so we catch
Expand All @@ -139,12 +141,25 @@ export default function BranchToolbar({
void promise
.then((result) => {
toastManager.add({
type: "success",
title: result.status === "pulled" ? "Pulled" : "Already up to date",
type: result.status === "conflicted" ? "warning" : "success",
title:
result.status === "pulled"
? "Pulled"
: result.status === "rebased"
? "Rebased"
: result.status === "conflicted"
? "Rebase stopped with conflicts"
: "Already up to date",
description:
result.status === "pulled"
? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}.`
: "Branch is already up to date.",
: result.status === "rebased"
? `Rebased ${result.branch} onto ${result.upstreamBranch ?? "upstream"}.`
: result.status === "conflicted"
? result.conflictedFiles.length > 0
? `Resolve ${result.conflictedFiles.length} conflicted file${result.conflictedFiles.length === 1 ? "" : "s"} in ${result.cwd}.`
: `Resolve conflicts in ${result.cwd} before continuing.`
: "Branch is already up to date.",
});
})
.catch((error) => {
Expand Down Expand Up @@ -210,7 +225,7 @@ export default function BranchToolbar({

<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1.5">
{isBehindUpstream ? (
{needsSync ? (
<Tooltip>
<TooltipTrigger
render={
Expand All @@ -226,7 +241,7 @@ export default function BranchToolbar({
) : (
<ArrowDownIcon className="size-3" />
)}
Pull
{isDiverged ? "Rebase" : "Pull"}
<Badge
variant="outline"
size="sm"
Expand All @@ -238,8 +253,9 @@ export default function BranchToolbar({
}
/>
<TooltipPopup side="bottom" align="end">
Local branch is {behindCount} commit{behindCount !== 1 ? "s" : ""} behind upstream.
Pull to update before starting a new thread.
{isDiverged
? `Local branch has diverged from upstream (${aheadCount} ahead, ${behindCount} behind). Rebase before starting a new thread.`
: `Local branch is ${behindCount} commit${behindCount !== 1 ? "s" : ""} behind upstream. Pull to update before starting a new thread.`}
</TooltipPopup>
</Tooltip>
) : null}
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/components/GitActionsControl.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,13 +422,13 @@ describe("when: branch is behind upstream", () => {
});

describe("when: branch has diverged from upstream", () => {
it("resolveQuickAction returns a disabled sync hint", () => {
it("resolveQuickAction offers a sync action", () => {
const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false);
assert.deepEqual(quick, {
label: "Sync branch",
disabled: true,
kind: "show_hint",
hint: "Branch has diverged from upstream. Rebase/merge first.",
disabled: false,
kind: "run_pull",
hint: "Branch has diverged from upstream. Rebase onto the remote branch.",
});
});
});
Expand All @@ -453,13 +453,13 @@ describe("sync button state", () => {
});
});

it("returns a disabled sync hint when the branch has diverged from upstream", () => {
it("returns a rebase sync action when the branch has diverged from upstream", () => {
const syncAction = resolveSyncAction(status({ aheadCount: 2, behindCount: 1 }), false);
assert.deepEqual(syncAction, {
label: "Sync branch",
disabled: true,
kind: "show_hint",
hint: "Branch has diverged from upstream. Rebase/merge first.",
disabled: false,
kind: "run_pull",
hint: "Branch has diverged from upstream. Rebase onto the remote branch.",
});
});

Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/GitActionsControl.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,9 @@ export function resolveQuickAction(
if (isDiverged) {
return {
label: "Sync branch",
disabled: true,
kind: "show_hint",
hint: "Branch has diverged from upstream. Rebase/merge first.",
disabled: false,
kind: "run_pull",
hint: "Branch has diverged from upstream. Rebase onto the remote branch.",
};
}

Expand Down Expand Up @@ -414,9 +414,9 @@ export function resolveSyncAction(
if (isAhead && isBehind) {
return {
label: "Sync branch",
disabled: true,
kind: "show_hint",
hint: "Branch has diverged from upstream. Rebase/merge first.",
disabled: false,
kind: "run_pull",
hint: "Branch has diverged from upstream. Rebase onto the remote branch.",
};
}

Expand Down
Loading
Loading