fix(git): show Sync only when branch diverges from origin
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||||
|
|
||||||
|
describe("git-action-visibility", () => {
|
||||||
|
describe("shouldShowMergeFromBaseAction", () => {
|
||||||
|
it("shows on non-base branches", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
hasRemote: false,
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides on base branch when no remote exists", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch: true,
|
||||||
|
hasRemote: false,
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides on base branch when local is in sync with origin", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch: true,
|
||||||
|
hasRemote: true,
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows on base branch when ahead of origin", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch: true,
|
||||||
|
hasRemote: true,
|
||||||
|
aheadOfOrigin: 1,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows on base branch when behind origin", () => {
|
||||||
|
expect(
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch: true,
|
||||||
|
hasRemote: true,
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 2,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function shouldShowMergeFromBaseAction(input: {
|
||||||
|
isOnBaseBranch: boolean;
|
||||||
|
hasRemote: boolean;
|
||||||
|
aheadOfOrigin: number;
|
||||||
|
behindOfOrigin: number;
|
||||||
|
}): boolean {
|
||||||
|
if (!input.isOnBaseBranch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!input.hasRemote) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
} from "@/components/web-desktop-scrollbar";
|
} from "@/components/web-desktop-scrollbar";
|
||||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Git Actions Data Structure
|
// Git Actions Data Structure
|
||||||
@@ -900,6 +901,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||||
|
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||||
const baseRefLabel = useMemo(() => {
|
const baseRefLabel = useMemo(() => {
|
||||||
if (!baseRef) return "base";
|
if (!baseRef) return "base";
|
||||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||||
@@ -1109,7 +1111,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update/sync from base
|
// Update/sync from base
|
||||||
if (!isOnBaseBranch || hasRemote) {
|
if (
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch,
|
||||||
|
hasRemote,
|
||||||
|
aheadOfOrigin,
|
||||||
|
behindOfOrigin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
allActions.set("merge-from-base", {
|
allActions.set("merge-from-base", {
|
||||||
id: "merge-from-base",
|
id: "merge-from-base",
|
||||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||||
@@ -1203,7 +1212,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
return { primary, secondary, menu };
|
return { primary, secondary, menu };
|
||||||
}, [
|
}, [
|
||||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ describe('DaemonClient', () => {
|
|||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3437,6 +3437,7 @@ export class Session {
|
|||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
@@ -3460,6 +3461,7 @@ export class Session {
|
|||||||
baseRef: status.baseRef,
|
baseRef: status.baseRef,
|
||||||
aheadBehind: status.aheadBehind ?? null,
|
aheadBehind: status.aheadBehind ?? null,
|
||||||
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
||||||
|
behindOfOrigin: status.behindOfOrigin ?? null,
|
||||||
hasRemote: status.hasRemote,
|
hasRemote: status.hasRemote,
|
||||||
remoteUrl: status.remoteUrl,
|
remoteUrl: status.remoteUrl,
|
||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
@@ -3481,6 +3483,7 @@ export class Session {
|
|||||||
baseRef: status.baseRef ?? null,
|
baseRef: status.baseRef ?? null,
|
||||||
aheadBehind: status.aheadBehind ?? null,
|
aheadBehind: status.aheadBehind ?? null,
|
||||||
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
||||||
|
behindOfOrigin: status.behindOfOrigin ?? null,
|
||||||
hasRemote: status.hasRemote,
|
hasRemote: status.hasRemote,
|
||||||
remoteUrl: status.remoteUrl,
|
remoteUrl: status.remoteUrl,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
@@ -3500,6 +3503,7 @@ export class Session {
|
|||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
|
|||||||
@@ -1562,6 +1562,7 @@ const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
|||||||
baseRef: z.null(),
|
baseRef: z.null(),
|
||||||
aheadBehind: z.null(),
|
aheadBehind: z.null(),
|
||||||
aheadOfOrigin: z.null(),
|
aheadOfOrigin: z.null(),
|
||||||
|
behindOfOrigin: z.null(),
|
||||||
hasRemote: z.boolean(),
|
hasRemote: z.boolean(),
|
||||||
remoteUrl: z.null(),
|
remoteUrl: z.null(),
|
||||||
})
|
})
|
||||||
@@ -1575,6 +1576,7 @@ const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({
|
|||||||
baseRef: z.string().nullable(),
|
baseRef: z.string().nullable(),
|
||||||
aheadBehind: AheadBehindSchema.nullable(),
|
aheadBehind: AheadBehindSchema.nullable(),
|
||||||
aheadOfOrigin: z.number().nullable(),
|
aheadOfOrigin: z.number().nullable(),
|
||||||
|
behindOfOrigin: z.number().nullable(),
|
||||||
hasRemote: z.boolean(),
|
hasRemote: z.boolean(),
|
||||||
remoteUrl: z.string().nullable(),
|
remoteUrl: z.string().nullable(),
|
||||||
})
|
})
|
||||||
@@ -1589,6 +1591,7 @@ const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({
|
|||||||
baseRef: z.string(),
|
baseRef: z.string(),
|
||||||
aheadBehind: AheadBehindSchema.nullable(),
|
aheadBehind: AheadBehindSchema.nullable(),
|
||||||
aheadOfOrigin: z.number().nullable(),
|
aheadOfOrigin: z.number().nullable(),
|
||||||
|
behindOfOrigin: z.number().nullable(),
|
||||||
hasRemote: z.boolean(),
|
hasRemote: z.boolean(),
|
||||||
remoteUrl: z.string().nullable(),
|
remoteUrl: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -101,6 +101,43 @@ describe("checkout git utilities", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports ahead/behind relative to origin on the base branch", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
const cloneDir = join(tempDir, "clone");
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
|
||||||
|
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: cloneDir });
|
||||||
|
writeFileSync(join(cloneDir, "file.txt"), "remote\n");
|
||||||
|
execSync("git add file.txt", { cwd: cloneDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'remote update'", { cwd: cloneDir });
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
const behindStatus = await getCheckoutStatus(repoDir);
|
||||||
|
expect(behindStatus.isGit).toBe(true);
|
||||||
|
if (!behindStatus.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(behindStatus.aheadOfOrigin).toBe(0);
|
||||||
|
expect(behindStatus.behindOfOrigin).toBe(1);
|
||||||
|
|
||||||
|
writeFileSync(join(repoDir, "local.txt"), "local\n");
|
||||||
|
execSync("git add local.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'local update'", { cwd: repoDir });
|
||||||
|
|
||||||
|
const divergedStatus = await getCheckoutStatus(repoDir);
|
||||||
|
expect(divergedStatus.isGit).toBe(true);
|
||||||
|
if (!divergedStatus.isGit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(divergedStatus.aheadOfOrigin).toBe(1);
|
||||||
|
expect(divergedStatus.behindOfOrigin).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("commits messages with quotes safely", async () => {
|
it("commits messages with quotes safely", async () => {
|
||||||
const message = `He said "hello" and it's fine`;
|
const message = `He said "hello" and it's fine`;
|
||||||
writeFileSync(join(repoDir, "file.txt"), "quoted\n");
|
writeFileSync(join(repoDir, "file.txt"), "quoted\n");
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ export type CheckoutStatusGitNonPaseo = {
|
|||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
aheadBehind: AheadBehind | null;
|
aheadBehind: AheadBehind | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
|
behindOfOrigin: number | null;
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
isPaseoOwnedWorktree: false;
|
isPaseoOwnedWorktree: false;
|
||||||
@@ -455,6 +456,7 @@ export type CheckoutStatusGitPaseo = {
|
|||||||
baseRef: string;
|
baseRef: string;
|
||||||
aheadBehind: AheadBehind | null;
|
aheadBehind: AheadBehind | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
|
behindOfOrigin: number | null;
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
isPaseoOwnedWorktree: true;
|
isPaseoOwnedWorktree: true;
|
||||||
@@ -838,6 +840,22 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getBehindOfOrigin(cwd: string, currentBranch: string): Promise<number | null> {
|
||||||
|
if (!currentBranch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`git rev-list --count ${currentBranch}..origin/${currentBranch}`,
|
||||||
|
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||||
|
);
|
||||||
|
const count = Number.parseInt(stdout.trim(), 10);
|
||||||
|
return Number.isNaN(count) ? null : count;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CheckoutInspectionContext = {
|
type CheckoutInspectionContext = {
|
||||||
worktreeRoot: string;
|
worktreeRoot: string;
|
||||||
currentBranch: string | null;
|
currentBranch: string | null;
|
||||||
@@ -996,10 +1014,11 @@ export async function getCheckoutStatus(
|
|||||||
const isDirty = await isWorkingTreeDirty(cwd);
|
const isDirty = await isWorkingTreeDirty(cwd);
|
||||||
const hasRemote = remoteUrl !== null;
|
const hasRemote = remoteUrl !== null;
|
||||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||||
const aheadBehind =
|
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
|
||||||
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
baseRef && currentBranch ? getAheadBehind(cwd, baseRef, currentBranch) : Promise.resolve(null),
|
||||||
const aheadOfOrigin =
|
hasRemote && currentBranch ? getAheadOfOrigin(cwd, currentBranch) : Promise.resolve(null),
|
||||||
hasRemote && currentBranch ? await getAheadOfOrigin(cwd, currentBranch) : null;
|
hasRemote && currentBranch ? getBehindOfOrigin(cwd, currentBranch) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
if (configured.isPaseoOwnedWorktree) {
|
if (configured.isPaseoOwnedWorktree) {
|
||||||
const mainRepoRoot = await getMainRepoRoot(cwd);
|
const mainRepoRoot = await getMainRepoRoot(cwd);
|
||||||
@@ -1012,6 +1031,7 @@ export async function getCheckoutStatus(
|
|||||||
baseRef: configured.baseRef,
|
baseRef: configured.baseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
|
behindOfOrigin,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
@@ -1026,6 +1046,7 @@ export async function getCheckoutStatus(
|
|||||||
baseRef,
|
baseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
|
behindOfOrigin,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user