Skip to content

Commit 97e3ada

Browse files
committed
fix: enable diverged branch sync and target origin repo for PR creation
- Branch sync: when local branch diverges from remote, allow force-pull (reset to upstream) if working tree is clean - PR creation: pass --repo flag to gh pr create so PRs target the correct fork repository - Update GitActionsControl tests for new pull menu item
1 parent 60f7ae8 commit 97e3ada

9 files changed

Lines changed: 197 additions & 21 deletions

File tree

apps/server/src/git/Layers/GitCore.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,6 +1424,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
14241424
},
14251425
);
14261426

1427+
const resetToUpstream: GitCoreShape["resetToUpstream"] = (cwd) =>
1428+
Effect.gen(function* () {
1429+
const upstream = yield* runGitStdout(
1430+
"GitCore.resetToUpstream.upstream",
1431+
cwd,
1432+
["rev-parse", "--abbrev-ref", "@{upstream}"],
1433+
);
1434+
yield* runGit("GitCore.resetToUpstream.reset", cwd, [
1435+
"reset",
1436+
"--hard",
1437+
upstream.trim(),
1438+
]);
1439+
});
1440+
14271441
const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")(
14281442
function* (cwd, baseBranch) {
14291443
const range = `${baseBranch}..HEAD`;
@@ -1966,6 +1980,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
19661980
commit,
19671981
pushCurrentBranch,
19681982
pullCurrentBranch,
1983+
resetToUpstream,
19691984
readRangeContext,
19701985
readConfigValue,
19711986
isInsideWorkTree,

apps/server/src/git/Layers/GitHubCli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ const makeGitHubCli = Effect.sync(() => {
247247
args: [
248248
"pr",
249249
"create",
250+
...(input.repo ? ["--repo", input.repo] : []),
250251
"--base",
251252
input.baseBranch,
252253
"--head",

apps/server/src/git/Layers/GitManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ interface BranchHeadContext {
7474
remoteName: string | null;
7575
headRepositoryNameWithOwner: string | null;
7676
headRepositoryOwnerLogin: string | null;
77+
/** The origin remote's owner/repo — used to target the correct repo for PR creation in forks. */
78+
originRepositoryNameWithOwner: string | null;
7779
isCrossRepository: boolean;
7880
}
7981

@@ -808,6 +810,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
808810
remoteName,
809811
headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
810812
headRepositoryOwnerLogin: remoteRepository.ownerLogin,
813+
originRepositoryNameWithOwner: originRepository.repositoryNameWithOwner,
811814
isCrossRepository,
812815
} satisfies BranchHeadContext;
813816
});
@@ -1261,6 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
12611264
headSelector: headContext.preferredHeadSelector,
12621265
title: generated.title,
12631266
bodyFile,
1267+
repo: headContext.originRepositoryNameWithOwner,
12641268
})
12651269
.pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
12661270

apps/server/src/git/Services/GitCore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ export interface GitCoreShape {
230230
*/
231231
readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>;
232232

233+
/**
234+
* Hard-reset the current branch to its upstream tracking ref.
235+
*/
236+
readonly resetToUpstream: (cwd: string) => Effect.Effect<void, GitCommandError>;
237+
233238
/**
234239
* Create a worktree and branch from a base branch.
235240
*/

apps/server/src/git/Services/GitHubCli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export interface GitHubCliShape {
7676
readonly headSelector: string;
7777
readonly title: string;
7878
readonly bodyFile: string;
79+
/** Optional owner/repo to target. Without this, `gh` defaults to the upstream parent for forks. */
80+
readonly repo?: string | null;
7981
}) => Effect.Effect<void, GitHubCliError>;
8082

8183
/**

apps/server/src/ws.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,29 @@ const WsRpcLayer = WsRpcGroup.toLayer(
222222
),
223223
[WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input),
224224
[WS_METHODS.gitStatus]: (input) => gitManager.status(input),
225-
[WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd),
225+
[WS_METHODS.gitPull]: (input) =>
226+
git.pullCurrentBranch(input.cwd).pipe(
227+
Effect.catch(() =>
228+
Effect.gen(function* () {
229+
const details = yield* git.statusDetails(input.cwd);
230+
if (details.hasWorkingTreeChanges) {
231+
return yield* Effect.fail(
232+
new GitManagerServiceError({
233+
message:
234+
"Branch has diverged and has local changes. Stash or commit changes before syncing.",
235+
}),
236+
);
237+
}
238+
yield* git.resetToUpstream(input.cwd);
239+
const refreshed = yield* git.statusDetails(input.cwd);
240+
return {
241+
status: "pulled" as const,
242+
branch: refreshed.branch ?? "unknown",
243+
upstreamBranch: refreshed.upstreamRef,
244+
};
245+
}),
246+
),
247+
),
226248
[WS_METHODS.gitRunStackedAction]: (input) =>
227249
Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) =>
228250
gitManager

apps/web/src/components/GitActionsControl.logic.test.ts

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ describe("when: branch is clean and has an open PR", () => {
6969
kind: "open_dialog",
7070
dialogAction: "commit",
7171
},
72+
{
73+
id: "pull",
74+
label: "Pull",
75+
disabled: true,
76+
icon: "pull",
77+
kind: "run_pull",
78+
},
7279
{
7380
id: "push",
7481
label: "Push",
@@ -110,6 +117,13 @@ describe("when: actions are busy", () => {
110117
kind: "open_dialog",
111118
dialogAction: "commit",
112119
},
120+
{
121+
id: "pull",
122+
label: "Pull",
123+
disabled: true,
124+
icon: "pull",
125+
kind: "run_pull",
126+
},
113127
{
114128
id: "push",
115129
label: "Push",
@@ -190,6 +204,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
190204
kind: "open_dialog",
191205
dialogAction: "commit",
192206
},
207+
{
208+
id: "pull",
209+
label: "Pull",
210+
disabled: true,
211+
icon: "pull",
212+
kind: "run_pull",
213+
},
193214
{
194215
id: "push",
195216
label: "Push",
@@ -230,6 +251,13 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
230251
kind: "open_dialog",
231252
dialogAction: "commit",
232253
},
254+
{
255+
id: "pull",
256+
label: "Pull",
257+
disabled: true,
258+
icon: "pull",
259+
kind: "run_pull",
260+
},
233261
{
234262
id: "push",
235263
label: "Push",
@@ -270,6 +298,13 @@ describe("when: branch is clean, up to date, and has no open PR", () => {
270298
kind: "open_dialog",
271299
dialogAction: "commit",
272300
},
301+
{
302+
id: "pull",
303+
label: "Pull",
304+
disabled: true,
305+
icon: "pull",
306+
kind: "run_pull",
307+
},
273308
{
274309
id: "push",
275310
label: "Push",
@@ -296,7 +331,7 @@ describe("when: branch is behind upstream", () => {
296331
assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false });
297332
});
298333

299-
it("buildMenuItems disables push and create PR", () => {
334+
it("buildMenuItems enables pull and disables push and create PR", () => {
300335
const items = buildMenuItems(status({ behindCount: 1, pr: null }), false);
301336
assert.deepEqual(items, [
302337
{
@@ -307,6 +342,13 @@ describe("when: branch is behind upstream", () => {
307342
kind: "open_dialog",
308343
dialogAction: "commit",
309344
},
345+
{
346+
id: "pull",
347+
label: "Pull",
348+
disabled: false,
349+
icon: "pull",
350+
kind: "run_pull",
351+
},
310352
{
311353
id: "push",
312354
label: "Push",
@@ -328,13 +370,12 @@ describe("when: branch is behind upstream", () => {
328370
});
329371

330372
describe("when: branch has diverged from upstream", () => {
331-
it("resolveQuickAction returns a disabled sync hint", () => {
373+
it("resolveQuickAction enables sync when working tree is clean", () => {
332374
const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false);
333375
assert.deepEqual(quick, {
334376
label: "Sync branch",
335-
disabled: true,
336-
kind: "show_hint",
337-
hint: "Branch has diverged from upstream. Rebase/merge first.",
377+
disabled: false,
378+
kind: "run_pull",
338379
});
339380
});
340381
});
@@ -397,6 +438,13 @@ describe("when: working tree has local changes", () => {
397438
kind: "open_dialog",
398439
dialogAction: "commit",
399440
},
441+
{
442+
id: "pull",
443+
label: "Pull",
444+
disabled: true,
445+
icon: "pull",
446+
kind: "run_pull",
447+
},
400448
{
401449
id: "push",
402450
label: "Push",
@@ -471,6 +519,13 @@ describe("when: working tree has local changes and branch is behind upstream", (
471519
kind: "open_dialog",
472520
dialogAction: "commit",
473521
},
522+
{
523+
id: "pull",
524+
label: "Pull",
525+
disabled: false,
526+
icon: "pull",
527+
kind: "run_pull",
528+
},
474529
{
475530
id: "push",
476531
label: "Push",
@@ -500,7 +555,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
500555
assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
501556
});
502557

503-
it("buildMenuItems keeps commit, push, and PR disabled", () => {
558+
it("buildMenuItems keeps commit, pull, push, and PR disabled", () => {
504559
const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false);
505560
assert.deepEqual(items, [
506561
{
@@ -511,6 +566,13 @@ describe("when: HEAD is detached and there are no local changes", () => {
511566
kind: "open_dialog",
512567
dialogAction: "commit",
513568
},
569+
{
570+
id: "pull",
571+
label: "Pull",
572+
disabled: true,
573+
icon: "pull",
574+
kind: "run_pull",
575+
},
514576
{
515577
id: "push",
516578
label: "Push",
@@ -603,6 +665,13 @@ describe("when: branch has no upstream configured", () => {
603665
kind: "open_dialog",
604666
dialogAction: "commit",
605667
},
668+
{
669+
id: "pull",
670+
label: "Pull",
671+
disabled: true,
672+
icon: "pull",
673+
kind: "run_pull",
674+
},
606675
{
607676
id: "push",
608677
label: "Push",
@@ -669,6 +738,13 @@ describe("when: branch has no upstream configured", () => {
669738
kind: "open_dialog",
670739
dialogAction: "commit",
671740
},
741+
{
742+
id: "pull",
743+
label: "Pull",
744+
disabled: true,
745+
icon: "pull",
746+
kind: "run_pull",
747+
},
672748
{
673749
id: "push",
674750
label: "Push",
@@ -703,6 +779,13 @@ describe("when: branch has no upstream configured", () => {
703779
kind: "open_dialog",
704780
dialogAction: "commit",
705781
},
782+
{
783+
id: "pull",
784+
label: "Pull",
785+
disabled: true,
786+
icon: "pull",
787+
kind: "run_pull",
788+
},
706789
{
707790
id: "push",
708791
label: "Push",
@@ -779,6 +862,13 @@ describe("when: branch has no upstream configured", () => {
779862
kind: "open_dialog",
780863
dialogAction: "commit",
781864
},
865+
{
866+
id: "pull",
867+
label: "Pull",
868+
disabled: true,
869+
icon: "pull",
870+
kind: "run_pull",
871+
},
782872
{
783873
id: "push",
784874
label: "Push",

0 commit comments

Comments
 (0)