Skip to content
Draft

Jujutsu #2185

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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ jobs:
pnpm-version: ${{ env.PNPM_VERSION }}
cache-prefix: test-${{ matrix.shard }}

# The @bb/host-workspace jj suites skip when the binary is absent, so
# without this step they would silently never run in CI.
- name: Install jj (Jujutsu) for colocated-repo tests
if: matrix.shard == 'packages'
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/jj-vcs/jj/releases/download/v0.44.0/jj-v0.44.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz -C "$HOME/.local/bin" ./jj
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Test
run: pnpm exec turbo run test ${{ matrix.filter }} --cache-dir=.turbo/cache --output-logs=new-only ${{ matrix.args }}

Expand Down
2 changes: 2 additions & 0 deletions apps/app/.ladle/story-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ export function makeThreadListEntry(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
environmentVcs: null,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
};
return { ...base, ...overrides };
Expand Down Expand Up @@ -466,6 +467,7 @@ export function makeEnvironment(
managed: true,
isGitRepo: true,
isWorktree: true,
vcs: null,
workspaceProvisionType: "managed-worktree",
branchName: BRANCH_NAMES.feature,
baseBranch: BRANCH_NAMES.default,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function createThreadListEntry({
environmentId: null,
environmentName: null,
environmentWorkspaceDisplayKind: "other",
environmentVcs: null,
hasPendingInteraction: false,
id,
lastReadAt: null,
Expand Down
54 changes: 44 additions & 10 deletions apps/app/src/components/pickers/EnvironmentPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useMemo } from "react";
import type { Host, ProjectSource } from "@bb/domain";
import type { Host, ProjectSource, WorkspaceVcs } from "@bb/domain";
import { Icon, type IconName } from "@bb/shared-ui/icon";
import { findLocalPathProjectSourceForHost } from "@bb/domain";
import {
findLocalPathProjectSourceForHost,
managedCheckoutNoun,
} from "@bb/domain";
import { Button } from "@bb/shared-ui/button";
import {
DropdownMenu,
Expand Down Expand Up @@ -78,6 +81,11 @@ export interface EnvironmentPickerUIProps {
reuseDisabled?: boolean;
/** Reason to disable "New worktree" while leaving local/remote work usable. */
worktreeDisabledReason?: string | null;
/**
* Which tool owns the project source's checkout. jj sources get workspaces
* rather than worktrees, and the picker names them that way.
*/
vcs?: WorkspaceVcs | null;
/** Render with the dim, hover-to-foreground treatment used inside the prompt box. */
muted?: boolean;
/** Render as a non-interactive label while preserving the selected mode. */
Expand All @@ -104,6 +112,7 @@ export function EnvironmentPickerUI({
isLocal,
reuseDisabled,
worktreeDisabledReason,
vcs,
muted,
disabled = false,
className,
Expand All @@ -112,6 +121,11 @@ export function EnvironmentPickerUI({
machines,
onRequestMachineSetup,
}: EnvironmentPickerUIProps) {
const checkoutNoun = managedCheckoutNoun(vcs);
const checkoutNounCapitalized = managedCheckoutNoun(vcs, {
capitalized: true,
});
const checkoutNounPlural = managedCheckoutNoun(vcs, { plural: true });
const hostId = host?.id ?? null;
const isMachineMenu = (machines?.hosts.length ?? 0) > 1;
const hostConnected = host?.status === "connected";
Expand Down Expand Up @@ -139,7 +153,7 @@ export function EnvironmentPickerUI({
const newWorktreeDisabledReason =
workspaceDisabledReason ?? worktreeDisabledReason ?? null;
const reuseDisabledReason = reuseDisabled
? "No worktrees in this project yet"
? `No ${checkoutNounPlural} in this project yet`
: null;

const parsed = useMemo(() => parseEnvironmentValue(value), [value]);
Expand Down Expand Up @@ -178,14 +192,19 @@ export function EnvironmentPickerUI({
}
if (parsed.type === "reuse") {
return {
modeLabel: "Reuse worktree",
modeLabel: `Reuse ${checkoutNoun}`,
compactModeLabel: "Reuse",
icon: getEnvironmentWorkspaceLabelIconName("managed-worktree"),
};
}
const modeLabel = parsed.mode === "worktree" ? "New worktree" : localLabel;
const modeLabel =
parsed.mode === "worktree" ? `New ${checkoutNoun}` : localLabel;
const compactModeLabel =
parsed.mode === "worktree" ? "Worktree" : isLocal ? "Local" : "Remote";
parsed.mode === "worktree"
? checkoutNounCapitalized
: isLocal
? "Local"
: "Remote";
const icon = getEnvironmentWorkspaceLabelIconName(
parsed.mode === "worktree" ? "managed-worktree" : "other",
);
Expand All @@ -197,6 +216,8 @@ export function EnvironmentPickerUI({
icon,
};
}, [
checkoutNoun,
checkoutNounCapitalized,
parsed,
localLabel,
isLocal,
Expand Down Expand Up @@ -262,6 +283,7 @@ export function EnvironmentPickerUI({
sources={sources}
selectedHostId={parsed?.type === "host" ? parsed.hostId : hostId}
worktreeDisabledReason={worktreeDisabledReason ?? null}
vcs={vcs}
reuseDisabledReason={reuseDisabledReason}
selectedType={parsed?.type}
value={value}
Expand All @@ -276,6 +298,7 @@ export function EnvironmentPickerUI({
localLabel={localLabel}
workspaceDisabledReason={workspaceDisabledReason}
worktreeDisabledReason={newWorktreeDisabledReason}
vcs={vcs}
reuseDisabledReason={reuseDisabledReason}
selectedType={parsed?.type}
value={value}
Expand All @@ -300,6 +323,8 @@ interface EnvironmentOptionsSectionProps {
workspaceDisabledReason: string | null;
/** Why the worktree option is unavailable, or null when usable. */
worktreeDisabledReason: string | null;
/** Which tool owns the source checkout, so rows name it correctly. */
vcs: WorkspaceVcs | null | undefined;
/** Why the reuse option is unavailable, or null when usable. */
reuseDisabledReason: string | null;
selectedType:
Expand All @@ -316,11 +341,13 @@ function EnvironmentOptionsSection({
localLabel,
workspaceDisabledReason,
worktreeDisabledReason,
vcs,
reuseDisabledReason,
selectedType,
value,
onChange,
}: EnvironmentOptionsSectionProps) {
const checkoutNoun = managedCheckoutNoun(vcs);
const localValue = hostId ? encodeHostValue(hostId, "local") : null;
const worktreeValue = hostId ? encodeHostValue(hostId, "worktree") : null;
const workspaceDisabled = workspaceDisabledReason !== null;
Expand Down Expand Up @@ -355,7 +382,7 @@ function EnvironmentOptionsSection({
}}
/>
<EnvironmentMenuItem
label="New worktree"
label={`New ${checkoutNoun}`}
description={worktreeDisabledDescription}
icon={getEnvironmentWorkspaceLabelIconName("managed-worktree")}
selected={worktreeValue !== null && value === worktreeValue}
Expand All @@ -365,7 +392,7 @@ function EnvironmentOptionsSection({
}}
/>
<EnvironmentMenuItem
label="Existing worktree"
label={`Existing ${checkoutNoun}`}
description={reuseDisabledReason ?? undefined}
icon={getEnvironmentWorkspaceLabelIconName("managed-worktree")}
selected={selectedType === "reuse"}
Expand All @@ -392,6 +419,8 @@ interface MachineGroupedEnvironmentOptionsProps {
* checkouts haven't been probed. */
selectedHostId: string | null;
worktreeDisabledReason: string | null;
/** Source checkout's tool, known only for the selected host. */
vcs: WorkspaceVcs | null | undefined;
reuseDisabledReason: string | null;
selectedType:
| NonNullable<ReturnType<typeof parseEnvironmentValue>>["type"]
Expand All @@ -406,6 +435,7 @@ function MachineGroupedEnvironmentOptions({
sources,
selectedHostId,
worktreeDisabledReason,
vcs,
reuseDisabledReason,
selectedType,
value,
Expand All @@ -432,6 +462,7 @@ function MachineGroupedEnvironmentOptions({
worktreeDisabledReason={
machineHost.id === selectedHostId ? worktreeDisabledReason : null
}
vcs={machineHost.id === selectedHostId ? vcs : null}
now={now}
value={value}
onChange={onChange}
Expand All @@ -441,7 +472,7 @@ function MachineGroupedEnvironmentOptions({
<DropdownMenuSeparator />
<DropdownMenuGroup>
<EnvironmentMenuItem
label="Existing worktree"
label={`Existing ${managedCheckoutNoun(vcs)}`}
description={reuseDisabledReason ?? undefined}
icon={getEnvironmentWorkspaceLabelIconName("managed-worktree")}
selected={selectedType === "reuse"}
Expand All @@ -460,6 +491,8 @@ interface MachineSectionProps {
source: ProjectSource | null;
/** Why "New worktree" is unavailable on this machine, or null when usable. */
worktreeDisabledReason: string | null;
/** Source checkout's tool, or null when this machine's is unknown. */
vcs: WorkspaceVcs | null | undefined;
now: number;
value: string;
onChange: (value: string) => void;
Expand All @@ -471,6 +504,7 @@ function MachineSection({
isThisMachine,
source,
worktreeDisabledReason,
vcs,
now,
value,
onChange,
Expand Down Expand Up @@ -511,7 +545,7 @@ function MachineSection({
onSelect={() => onChange(localValue)}
/>
<EnvironmentMenuItem
label="New worktree"
label={`New ${managedCheckoutNoun(vcs)}`}
description={
connected ? (worktreeDisabledReason ?? undefined) : undefined
}
Expand Down
11 changes: 11 additions & 0 deletions apps/app/src/components/promptbox/NewThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
type RootComposeBranchEnvironmentMode,
} from "@/views/root-compose-branch-ui";
import { useScopedBranchSelection } from "@/views/root-compose-branch-selection";
import type { WorkspaceVcs } from "@bb/domain";
import {
buildReuseThreadOptions,
resolveProjectSourceWorktreeDisabledReason,
Expand Down Expand Up @@ -693,6 +694,14 @@ export function NewThreadComposer({
branchesQuery.data,
);
const worktreeUnavailable = worktreeDisabledReason !== null;
// jj sources get workspaces, not worktrees; the picker names them from this.
const projectSourceCheckout = branchesQuery.data?.checkout;
const projectSourceVcs: WorkspaceVcs | null =
projectSourceCheckout?.kind === "detached" && projectSourceCheckout.jj
? "jj"
: projectSourceCheckout
? "git"
: null;
const requestsManagedWorktree =
isHostMode && parsedEnvironment.mode === "worktree";
const managedWorktreeUnavailable =
Expand Down Expand Up @@ -1285,6 +1294,7 @@ export function NewThreadComposer({
sources: projectSources,
reuseDisabled: reuseThreadOptions.length === 0,
worktreeDisabledReason,
vcs: projectSourceVcs,
disabled: locks.environment,
...(!isProjectless && options.onRequestMachineSetup
? { onRequestMachineSetup: options.onRequestMachineSetup }
Expand Down Expand Up @@ -1454,6 +1464,7 @@ export function NewThreadComposer({
supportsServiceTier,
submitDisabledReason,
textEffects,
projectSourceVcs,
worktreeDisabledReason,
worktreeUnavailable,
serviceTierFastLabel,
Expand Down
10 changes: 9 additions & 1 deletion apps/app/src/components/promptbox/NewThreadPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
type Ref,
type RefObject,
} from "react";
import type { Host, ProjectSource, PromptTextMention } from "@bb/domain";
import type {
Host,
ProjectSource,
PromptTextMention,
WorkspaceVcs,
} from "@bb/domain";
import type { ComposerView } from "@get-bb/plugin-sdk";
import type { ComposerTextEffectSource } from "@/lib/composer-text-effects";
import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners";
Expand Down Expand Up @@ -89,6 +94,8 @@ export interface NewThreadEnvironmentConfig {
* Caller signals the project has no worktree envs available. */
reuseDisabled?: boolean;
worktreeDisabledReason?: string | null;
/** Which tool owns the source checkout, so the picker names it correctly. */
vcs?: WorkspaceVcs | null;
disabled?: boolean;
}

Expand Down Expand Up @@ -495,6 +502,7 @@ export function ThreadEnvSlot({
onRequestMachineSetup={environment.onRequestMachineSetup}
reuseDisabled={environment.reuseDisabled}
worktreeDisabledReason={environment.worktreeDisabledReason}
vcs={environment.vcs}
disabled={environment.disabled}
className="shrink-0"
muted
Expand Down
10 changes: 8 additions & 2 deletions apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
environmentDirectoryName
? `${projectName} / ${environmentDirectoryName}`
: projectName;
// The formatted checkout labels its row "Bookmark" only for jj checkouts,
// which is the only jj signal this component receives.
const checkoutNoun =
environmentCheckout?.rowLabel === "Bookmark" ? "workspace" : "worktree";

return (
<div className="flex min-w-0 max-w-full items-center gap-2 pr-1.5">
Expand Down Expand Up @@ -149,14 +153,16 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
<TooltipTrigger asChild>
<button
type="button"
aria-label="Create new thread in this worktree"
aria-label={`Create new thread in this ${checkoutNoun}`}
onClick={onCreateNewThreadInWorktree}
className="-ml-1 inline-flex cursor-pointer shrink-0 items-center justify-center rounded-md px-1 py-0.5 text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground"
>
<Icon name="MessageSquarePlus" className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Create new thread in this worktree</TooltipContent>
<TooltipContent>
{`Create new thread in this ${checkoutNoun}`}
</TooltipContent>
</Tooltip>
) : null}
</div>
Expand Down
Loading