Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
},
);

const resetToUpstream: GitCoreShape["resetToUpstream"] = (cwd) =>
Effect.gen(function* () {
const upstream = yield* runGitStdout(
"GitCore.resetToUpstream.upstream",
cwd,
["rev-parse", "--abbrev-ref", "@{upstream}"],
);
yield* runGit("GitCore.resetToUpstream.reset", cwd, [
"reset",
"--hard",
upstream.trim(),
]);
});

const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")(
function* (cwd, baseBranch) {
const range = `${baseBranch}..HEAD`;
Expand Down Expand Up @@ -1966,6 +1980,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
commit,
pushCurrentBranch,
pullCurrentBranch,
resetToUpstream,
readRangeContext,
readConfigValue,
isInsideWorkTree,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/Layers/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ const makeGitHubCli = Effect.sync(() => {
args: [
"pr",
"create",
...(input.repo ? ["--repo", input.repo] : []),
"--base",
input.baseBranch,
"--head",
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ interface BranchHeadContext {
remoteName: string | null;
headRepositoryNameWithOwner: string | null;
headRepositoryOwnerLogin: string | null;
/** The origin remote's owner/repo — used to target the correct repo for PR creation in forks. */
originRepositoryNameWithOwner: string | null;
isCrossRepository: boolean;
}

Expand Down Expand Up @@ -808,6 +810,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
remoteName,
headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
headRepositoryOwnerLogin: remoteRepository.ownerLogin,
originRepositoryNameWithOwner: originRepository.repositoryNameWithOwner,
isCrossRepository,
} satisfies BranchHeadContext;
});
Expand Down Expand Up @@ -1261,6 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
headSelector: headContext.preferredHeadSelector,
title: generated.title,
bodyFile,
repo: headContext.originRepositoryNameWithOwner,
})
.pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/git/Services/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ export interface GitCoreShape {
*/
readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>;

/**
* Hard-reset the current branch to its upstream tracking ref.
*/
readonly resetToUpstream: (cwd: string) => Effect.Effect<void, GitCommandError>;

/**
* Create a worktree and branch from a base branch.
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/git/Services/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export interface GitHubCliShape {
readonly headSelector: string;
readonly title: string;
readonly bodyFile: string;
/** Optional owner/repo to target. Without this, `gh` defaults to the upstream parent for forks. */
readonly repo?: string | null;
}) => Effect.Effect<void, GitHubCliError>;

/**
Expand Down
29 changes: 28 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,34 @@ const WsRpcLayer = WsRpcGroup.toLayer(
),
[WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input),
[WS_METHODS.gitStatus]: (input) => gitManager.status(input),
[WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd),
[WS_METHODS.gitPull]: (input) =>
git.pullCurrentBranch(input.cwd).pipe(
Effect.catch((pullError) =>
Effect.gen(function* () {
// Only attempt reset-to-upstream when the branch has actually diverged.
// Re-throw for network errors, auth failures, missing upstream, etc.
const details = yield* git.statusDetails(input.cwd);
if (details.aheadCount === 0 || details.behindCount === 0) {
return yield* Effect.fail(pullError);
}
if (details.hasWorkingTreeChanges) {
return yield* Effect.fail(
new GitManagerServiceError({
message:
"Branch has diverged and has local changes. Stash or commit changes before syncing.",
}),
);
}
yield* git.resetToUpstream(input.cwd);
const refreshed = yield* git.statusDetails(input.cwd);
return {
status: "pulled" as const,
branch: refreshed.branch ?? "unknown",
upstreamBranch: refreshed.upstreamRef,
};
}),
),
),
[WS_METHODS.gitRunStackedAction]: (input) =>
Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) =>
gitManager
Expand Down
102 changes: 96 additions & 6 deletions apps/web/src/components/GitActionsControl.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ describe("when: branch is clean and has an open PR", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -110,6 +117,13 @@ describe("when: actions are busy", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -190,6 +204,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -230,6 +251,13 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -270,6 +298,13 @@ describe("when: branch is clean, up to date, and has no open PR", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand All @@ -296,7 +331,7 @@ describe("when: branch is behind upstream", () => {
assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false });
});

it("buildMenuItems disables push and create PR", () => {
it("buildMenuItems enables pull and disables push and create PR", () => {
const items = buildMenuItems(status({ behindCount: 1, pr: null }), false);
assert.deepEqual(items, [
{
Expand All @@ -307,6 +342,13 @@ describe("when: branch is behind upstream", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: false,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand All @@ -328,13 +370,12 @@ describe("when: branch is behind upstream", () => {
});

describe("when: branch has diverged from upstream", () => {
it("resolveQuickAction returns a disabled sync hint", () => {
it("resolveQuickAction enables sync when working tree is clean", () => {
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",
});
});
});
Expand Down Expand Up @@ -397,6 +438,13 @@ describe("when: working tree has local changes", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -471,6 +519,13 @@ describe("when: working tree has local changes and branch is behind upstream", (
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: false,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -500,7 +555,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
});

it("buildMenuItems keeps commit, push, and PR disabled", () => {
it("buildMenuItems keeps commit, pull, push, and PR disabled", () => {
const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false);
assert.deepEqual(items, [
{
Expand All @@ -511,6 +566,13 @@ describe("when: HEAD is detached and there are no local changes", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -603,6 +665,13 @@ describe("when: branch has no upstream configured", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -669,6 +738,13 @@ describe("when: branch has no upstream configured", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -703,6 +779,13 @@ describe("when: branch has no upstream configured", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down Expand Up @@ -779,6 +862,13 @@ describe("when: branch has no upstream configured", () => {
kind: "open_dialog",
dialogAction: "commit",
},
{
id: "pull",
label: "Pull",
disabled: true,
icon: "pull",
kind: "run_pull",
},
{
id: "push",
label: "Push",
Expand Down
Loading
Loading