Skip to content
144 changes: 144 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
aheadOfDefaultCount: 0,
pr: null,
});
}),
Expand Down Expand Up @@ -909,6 +910,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
aheadOfDefaultCount: 0,
pr: null,
});
}),
Expand Down Expand Up @@ -1671,6 +1673,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("pushes existing commits without committing dirty worktree changes", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n");
yield* runGit(repoDir, ["add", "push-dirty.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]);
fs.mkdirSync(path.join(repoDir, ".vercel"));
fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n");

const { manager } = yield* makeManager();
const result = yield* runStackedAction(manager, {
cwd: repoDir,
action: "push",
});

expect(result.commit.status).toBe("skipped_not_requested");
expect(result.push.status).toBe("pushed");
expect(result.pr.status).toBe("skipped_not_requested");
expect(
yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
Effect.map((output) => output.stdout.trim()),
),
).toContain("?? .vercel/");
expect(
yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe(
Effect.map((output) => output.stdout.trim()),
),
).toBe("Push dirty branch");
}),
);

it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -2417,6 +2454,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect(
"restores same-repository upstream tracking after local PR checkout without a remote ref",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]);
fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n");
yield* runGit(repoDir, ["add", "upstream.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]);
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]);
yield* runGit(repoDir, [
"update-ref",
"-d",
"refs/remotes/origin/feature/pr-local-upstream",
]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 65,
title: "Local upstream PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/65",
baseRefName: "main",
headRefName: "feature/pr-local-upstream",
state: "open",
isCrossRepository: false,
headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
headRepositoryOwnerLogin: "pingdotgg",
},
repositoryCloneUrls: {
"pingdotgg/codething-mvp": {
url: remoteDir,
sshUrl: remoteDir,
},
},
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "65",
mode: "local",
});

expect(result.worktreePath).toBeNull();
expect(result.branch).toBe("feature/pr-local-upstream");
expect(
(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
).toBe("origin/feature/pr-local-upstream");
}),
);

it.effect(
"restores same-repository upstream tracking when provider omits head repository metadata",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]);
fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n");
yield* runGit(repoDir, ["add", "no-head-repo.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]);
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]);
yield* runGit(repoDir, [
"update-ref",
"-d",
"refs/remotes/origin/feature/pr-local-no-head-repo",
]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 66,
title: "Local upstream PR without repo metadata",
url: "https://github.com/pingdotgg/codething-mvp/pull/66",
baseRefName: "main",
headRefName: "feature/pr-local-no-head-repo",
state: "open",
},
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "66",
mode: "local",
});

expect(result.worktreePath).toBeNull();
expect(result.branch).toBe("feature/pr-local-no-head-repo");
expect(
(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
).toBe("origin/feature/pr-local-no-head-repo");
}),
);

it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
29 changes: 23 additions & 6 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,22 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
localBranch = pullRequest.headBranch,
) {
const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) {
const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd);
yield* gitCore.fetchRemoteTrackingBranch({
cwd,
remoteName,
remoteBranch: pullRequest.headBranch,
});
yield* gitCore.setBranchUpstream({
cwd,
branch: localBranch,
remoteName,
remoteBranch: pullRequest.headBranch,
});
return;
}

if (repositoryNameWithOwner.length === 0) {
return;
}
Expand All @@ -588,6 +604,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
url: remoteUrl,
});

yield* gitCore.fetchRemoteTrackingBranch({
cwd,
remoteName,
remoteBranch: pullRequest.headBranch,
});
yield* gitCore.setBranchUpstream({
cwd,
branch: localBranch,
Expand Down Expand Up @@ -690,6 +711,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
aheadOfDefaultCount: 0,
} satisfies GitStatusDetails;
const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
const details = yield* gitCore
Expand Down Expand Up @@ -748,6 +770,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
hasUpstream: details.hasUpstream,
aheadCount: details.aheadCount,
behindCount: details.behindCount,
aheadOfDefaultCount: details.aheadOfDefaultCount,
pr,
} satisfies VcsStatusRemoteResult;
});
Expand Down Expand Up @@ -1593,12 +1616,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
"Feature-branch checkout is only supported for commit actions.",
);
}
if (input.action === "push" && initialStatus.hasWorkingTreeChanges) {
return yield* gitManagerError(
"runStackedAction",
"Commit or stash local changes before pushing.",
);
}
if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
return yield* gitManagerError(
"runStackedAction",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe("GitWorkflowService", () => {
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
aheadOfDefaultCount: 0,
pr: null,
});
}).pipe(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ function nonRepositoryStatus(): VcsStatusResult {
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
aheadOfDefaultCount: 0,
pr: null,
};
}
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
import * as GitLabCli from "./sourceControl/GitLabCli.ts";
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
Expand Down Expand Up @@ -165,7 +166,7 @@ const GitManagerLayerLive = GitManager.layer.pipe(
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(
SourceControlProviderRegistry.layer.pipe(
Layer.provide(GitHubCli.layer),
Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
Layer.provideMerge(VcsDriverRegistryLayerLive),
),
),
Expand Down
Loading
Loading