Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c8fb14e
feat(server): add jj driver, detection, process wrapper, ci setup, an…
THEjacob1000 Sep 12, 2026
82d9c4a
feat(server): jj-native checkpoints and review diffs
THEjacob1000 Sep 12, 2026
d59e752
feat(server): route workflow, hosting glue, and discovery by vcs kind
THEjacob1000 Sep 12, 2026
9f61fce
feat(web,mobile): jj terminology and shared vcs action logic
THEjacob1000 Sep 12, 2026
627e0dc
fix(jj): adapt support stack to current nightly
knocking4thcylinder Sep 25, 2026
bfdb54e
fix(jj): preserve workspace bookmark through setup and commit
knocking4thcylinder Sep 26, 2026
c566838
test(jj): cover commit and push on feature bookmark
knocking4thcylinder Sep 26, 2026
1a632bf
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 26, 2026
eef018e
feat(vcs): enable jj in existing Git repositories
knocking4thcylinder Sep 26, 2026
294b906
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 27, 2026
b56b4ef
fix(jj): address review findings across workspaces and clients
knocking4thcylinder Sep 27, 2026
578426c
fix(jj): allow commit IDs as workspace bases
knocking4thcylinder Sep 27, 2026
dac688e
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 27, 2026
6c83cbe
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 27, 2026
447f19e
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 27, 2026
af75bbd
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 28, 2026
be45bb0
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 28, 2026
6b66421
Merge upstream main into jj support
knocking4thcylinder Sep 29, 2026
2666956
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 29, 2026
5ee7958
fix(jj): compare merge changes against merged parents
knocking4thcylinder Sep 29, 2026
a0b4e1c
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 29, 2026
100cf27
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 29, 2026
980f7a6
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 30, 2026
590cb25
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 30, 2026
4d88b93
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Sep 30, 2026
9712070
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Oct 1, 2026
94526ab
Merge branch 'main' into feat/jj-support-nightly
knocking4thcylinder Oct 1, 2026
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
40 changes: 40 additions & 0 deletions .github/actions/setup-jj/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Setup Jujutsu
description: Install the jj CLI from the official jj-vcs release artifacts.

inputs:
version:
description: Jujutsu release tag to install.
required: false
default: v0.42.0

runs:
using: composite
steps:
- name: Install jj
shell: bash
env:
JJ_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail

case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64)
asset="jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
;;
*)
echo "Unsupported runner for jj install: ${RUNNER_OS}-${RUNNER_ARCH}" >&2
exit 1
;;
esac

install_dir="${RUNNER_TEMP}/jj-${JJ_VERSION}"
mkdir -p "$install_dir"

curl -fsSL \
"https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${asset}" \
-o "${RUNNER_TEMP}/${asset}"
tar -xzf "${RUNNER_TEMP}/${asset}" -C "$install_dir"

chmod +x "${install_dir}/jj"
echo "$install_dir" >> "$GITHUB_PATH"
"${install_dir}/jj" --version
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ jobs:
cache: true
run-install: true

# The jj suites in apps/server/src/vcs skip themselves when jj is absent,
# so this step is what makes them actually run.
- uses: ./.github/actions/setup-jj

# No Electron setup here: `t3` (apps/server) has no Electron dependency
# and none of its tests touch the runtime. Only the non-server `test`
# job, which covers @t3tools/desktop, needs the download.
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ jobs:
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential

# `vp run test` includes the apps/server suite, whose jj tests skip without jj.
- uses: ./.github/actions/setup-jj

- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test

Expand Down
23 changes: 21 additions & 2 deletions apps/mobile/src/features/review/ReviewSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { getVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs";
import { useEnvironmentQuery } from "../../state/query";
import { vcsEnvironment } from "../../state/vcs";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
Expand Down Expand Up @@ -71,6 +74,7 @@ import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
function ReviewHeader(
props: Parameters<typeof useReviewHeaderPresentation>[0] & {
readonly iconColor: string;
readonly vcsTerminology: VcsTerminology;
readonly sectionMenu: ReturnType<typeof buildReviewSectionMenu>;
readonly showSectionToolbar: boolean;
readonly showChangedFilesToggle: boolean;
Expand Down Expand Up @@ -126,8 +130,14 @@ function ReviewHeader(
id: "sections",
inline: true,
items: [
sectionAction(props.sectionMenu.workingTree, "Working tree"),
sectionAction(props.sectionMenu.branchChanges, "Branch changes"),
sectionAction(
props.sectionMenu.workingTree,
props.vcsTerminology.workingTreeNounTitle,
),
sectionAction(
props.sectionMenu.branchChanges,
`${props.vcsTerminology.refNounTitle} changes`,
),
sectionAction(props.sectionMenu.latestTurn, "Latest turn"),
],
},
Expand Down Expand Up @@ -461,6 +471,13 @@ export function ReviewSheet(props: ReviewSheetProps) {
const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
const reviewCache = useReviewCacheForThread({ environmentId, threadId });
const { selectedThreadCwd } = useSelectedThreadWorktree();
const gitStatusQuery = useEnvironmentQuery(
selectedThreadCwd === null
? null
: vcsEnvironment.status({ environmentId, input: { cwd: selectedThreadCwd } }),
);
const vcsKind = gitStatusQuery.data?.vcs?.kind ?? null;
const vcsTerminology = getVcsTerminology(vcsKind);
// With a solid (non-overlay) header the content lays out below the header
// natively, so no manual top inset is needed. (Android renders its own
// in-flow AndroidScreenHeader, so it needs no inset either.)
Expand All @@ -482,6 +499,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
environmentId,
threadId,
reviewCache,
vcsKind,
});
useReviewDiffPrewarming({
threadKey: reviewCache.threadKey,
Expand Down Expand Up @@ -724,6 +742,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
subtitle={headerSubtitle}
androidSubtitle={androidHeaderSubtitle}
iconColor={headerIcon}
vcsTerminology={vcsTerminology}
selectedThreadCwd={selectedThreadCwd}
sectionMenu={sectionMenu}
selectedSection={selectedSection}
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/review/reviewModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,30 @@ describe("buildReviewSectionItems", () => {
]);
expect(getDefaultReviewSectionId(items)).toBe("git:working-tree");
});

it("names a missing base with the project's ref noun", () => {
const [section] = buildReviewSectionItems({
checkpoints: [],
gitSections: [
{
id: "branch-range",
kind: "branch-range",
title: "Bookmark changes",
baseRef: null,
headRef: null,
diff: "",
diffHash: "h",
truncated: false,
},
],
turnDiffById: {},
loadingTurnIds: {},
loadingGitSections: false,
vcsKind: "jj",
});

expect(section?.subtitle).toBe("Base bookmark unavailable");
});
});

describe("buildReviewParsedDiff", () => {
Expand Down
29 changes: 20 additions & 9 deletions apps/mobile/src/features/review/reviewModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
import type {
OrchestrationCheckpointSummary,
ReviewDiffPreviewSource,
VcsDriverKind,
} from "@t3tools/contracts";
import { getVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs";
import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
Expand All @@ -9,8 +14,6 @@ import * as Order from "effect/Order";
export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";

const DIRTY_WORKTREE_SECTION_ID = "git:working-tree";
const DIRTY_WORKTREE_TITLE = "Dirty worktree";
const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes";

export interface ReviewSectionItem {
readonly id: string;
Expand Down Expand Up @@ -121,14 +124,18 @@ const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>(
compareCheckpointTurnCountDescending,
);

function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
function workingTreeSubtitle(terminology: VcsTerminology): string {
return `Tracked, staged, and untracked ${terminology.workspaceNoun} changes`;
}

function gitSubtitle(section: ReviewDiffPreviewSource, terminology: VcsTerminology): string | null {
if (section.kind === "working-tree") {
return DIRTY_WORKTREE_SUBTITLE;
return workingTreeSubtitle(terminology);
}
if (section.baseRef) {
return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
}
return "Base branch unavailable";
return `Base ${terminology.refNoun} unavailable`;
}

function stripTrailingNewline(value: string): string {
Expand Down Expand Up @@ -416,7 +423,9 @@ export function buildReviewSectionItems(input: {
readonly turnDiffById: Readonly<Record<string, string | undefined>>;
readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>;
readonly loadingGitSections: boolean;
readonly vcsKind?: VcsDriverKind | null;
}): ReadonlyArray<ReviewSectionItem> {
const terminology = getVcsTerminology(input.vcsKind);
const turnItems = getReadyReviewCheckpoints(input.checkpoints).map<ReviewSectionItem>(
(checkpoint) => {
const id = getReviewSectionIdForCheckpoint(checkpoint);
Expand All @@ -435,7 +444,7 @@ export function buildReviewSectionItems(input: {
id: `git:${section.kind}`,
kind: section.kind,
title: section.title,
subtitle: gitSubtitle(section),
subtitle: gitSubtitle(section, terminology),
diff: section.diff,
source: section,
...(section.files ? { files: section.files } : {}),
Expand All @@ -449,8 +458,10 @@ export function buildReviewSectionItems(input: {
{
id: DIRTY_WORKTREE_SECTION_ID,
kind: "working-tree",
title: DIRTY_WORKTREE_TITLE,
subtitle: DIRTY_WORKTREE_SUBTITLE,
// Mirrors the title each driver sends for this section, so the
// placeholder does not flicker into a different name on arrival.
title: input.vcsKind === "jj" ? "Working copy" : "Dirty worktree",
subtitle: workingTreeSubtitle(terminology),
diff: null,
isLoading: true,
} satisfies ReviewSectionItem,
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/features/review/useReviewSections.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useCallback, useEffect, useMemo } from "react";
import * as DateTime from "effect/DateTime";

import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationCheckpointSummary,
ThreadId,
VcsDriverKind,
} from "@t3tools/contracts";

import { useCheckpointDiff } from "../../state/queries";
import { useEnvironmentQuery } from "../../state/query";
Expand All @@ -28,8 +33,10 @@ export function useReviewSections(input: {
readonly environmentId?: EnvironmentId;
readonly threadId?: ThreadId;
readonly reviewCache: ReviewCacheForThread;
readonly vcsKind?: VcsDriverKind | null;
}) {
const { environmentId, reviewCache, threadId } = input;
const vcsKind = input.vcsKind ?? null;
const enabled = input.enabled ?? true;
const selectedThread = useSelectedThreadDetail();
const { selectedThreadCwd } = useSelectedThreadWorktree();
Expand Down Expand Up @@ -71,6 +78,7 @@ export function useReviewSections(input: {
turnDiffById: reviewCache.turnDiffById,
loadingTurnIds,
loadingGitSections: diffPreview.isPending,
vcsKind,
}),
[
diffPreview.isPending,
Expand All @@ -79,6 +87,7 @@ export function useReviewSections(input: {
readyCheckpoints,
reviewCache.gitSections,
reviewCache.turnDiffById,
vcsKind,
],
);
const selectedSection = useMemo(
Expand Down
33 changes: 22 additions & 11 deletions apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ export function NewTaskBranchPickerRouteScreen() {
const selectingBranchNameRef = useRef<string | null>(null);
const allowSelectionNavigationRef = useRef(false);
const mountedRef = useRef(true);
const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch";
const screenTitle =
flow.workspaceMode === "worktree"
? `Base ${flow.vcsTerminology.refNoun}`
: flow.vcsTerminology.refNounTitle;
const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const selectedBranchName =
flow.selectedBranchName ??
Expand Down Expand Up @@ -327,8 +330,10 @@ export function NewTaskBranchPickerRouteScreen() {
if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Alert.alert(
"Could not switch branch",
error instanceof Error ? error.message : "The branch could not be checked out.",
`Could not switch ${flow.vcsTerminology.refNoun}`,
error instanceof Error
? error.message
: `The ${flow.vcsTerminology.refNoun} could not be checked out.`,
);
}
return;
Expand Down Expand Up @@ -357,6 +362,7 @@ export function NewTaskBranchPickerRouteScreen() {
flow.selectedProject,
flow.setBranchQuery,
flow.workspaceMode,
flow.vcsTerminology,
navigation,
switchRef,
],
Expand All @@ -365,7 +371,11 @@ export function NewTaskBranchPickerRouteScreen() {
const renderBranch = useCallback(
({ item, index }: { readonly item: VcsRef; readonly index: number }) => (
<BranchSelectionRow
badge={branchBadgeLabel({ branch: item, project: flow.selectedProject })}
badge={branchBadgeLabel({
branch: item,
project: flow.selectedProject,
terminology: flow.vcsTerminology,
})}
branch={item}
disabled={switchingBranchName !== null}
isFirst={index === 0}
Expand All @@ -377,6 +387,7 @@ export function NewTaskBranchPickerRouteScreen() {
[
flow.filteredBranches.length,
flow.selectedProject,
flow.vcsTerminology,
selectBranch,
selectedBranchName,
switchingBranchName,
Expand Down Expand Up @@ -420,12 +431,12 @@ export function NewTaskBranchPickerRouteScreen() {
{flow.branchesLoading ? <ActivityIndicator /> : null}
<Text className="text-center text-sm text-foreground-muted">
{flow.branchesLoading
? "Loading branches…"
? `Loading ${flow.vcsTerminology.refNounPlural}…`
: flow.branchesError
? flow.branchesError
: flow.branchQuery
? "No matching branches"
: "No branches available"}
? `No matching ${flow.vcsTerminology.refNounPlural}`
: `No ${flow.vcsTerminology.refNounPlural} available`}
</Text>
{!flow.branchesLoading && flow.branchesError ? (
<Pressable
Expand Down Expand Up @@ -480,13 +491,13 @@ export function NewTaskBranchPickerRouteScreen() {
<TextInput
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="Find a branch"
accessibilityLabel={`Find a ${flow.vcsTerminology.refNoun}`}
className="h-12 rounded-full border border-input-border bg-input px-4 font-sans text-base text-foreground"
selectionColorClassName="accent-focus/32"
cursorColorClassName="accent-focus"
selectionHandleColorClassName="accent-focus"
onChangeText={flow.setBranchQuery}
placeholder="Find a branch"
placeholder={`Find a ${flow.vcsTerminology.refNoun}`}
placeholderTextColorClassName="accent-placeholder"
value={flow.branchQuery}
/>
Expand All @@ -506,7 +517,7 @@ export function NewTaskBranchPickerRouteScreen() {
? () => [
createNativeMailSearchToolbarItem({
onSearchTextChange: flow.setBranchQuery,
placeholder: "Find a branch",
placeholder: `Find a ${flow.vcsTerminology.refNoun}`,
searchTextChangeId: "new-task-branch-search-text",
showsSearchDismissButton: true,
}),
Expand All @@ -519,7 +530,7 @@ export function NewTaskBranchPickerRouteScreen() {
autoCapitalize: "none",
hideNavigationBar: false,
obscureBackground: false,
placeholder: "Find a branch",
placeholder: `Find a ${flow.vcsTerminology.refNoun}`,
onChangeText: (event) => {
flow.setBranchQuery(event.nativeEvent.text);
},
Expand Down
Loading
Loading