From 84e9391871175e6f370f3c91d04cbdfc2e5eb4d5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 28 Jan 2026 02:10:40 +0200 Subject: [PATCH] feat: add integrate commits to parental branch section and wired UI Introduce IntegrateCommitsSection to plan and apply commits from source to target branch Wire the integration panel into GitView so users can run integrate flow from the current worktree --- packages/ui/src/components/views/GitView.tsx | 36 +- .../components/views/git/CommitSection.tsx | 19 +- .../views/git/IntegrateCommitsSection.tsx | 482 ++++++++++++++++++ packages/ui/src/index.css | 17 + packages/ui/src/lib/execCommands.ts | 57 +++ .../src/lib/git/integrateWorktreeCommits.ts | 293 +++++++++++ packages/ui/src/lib/openchamberConfig.ts | 1 + packages/ui/src/lib/worktreeSessionCreator.ts | 27 +- packages/ui/src/stores/sessionStore.ts | 2 +- packages/ui/src/stores/useAgentGroupsStore.ts | 1 + packages/ui/src/stores/useMultiRunStore.ts | 7 +- packages/ui/src/types/worktree.ts | 6 + 12 files changed, 932 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/views/git/IntegrateCommitsSection.tsx create mode 100644 packages/ui/src/lib/execCommands.ts create mode 100644 packages/ui/src/lib/git/integrateWorktreeCommits.ts diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 2dccd00c..3c935ad3 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -23,6 +23,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +// (dropdown menu used inside IntegrateCommitsSection) import { Command, CommandEmpty, @@ -34,6 +35,7 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; +import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; import { GitEmptyState } from './git/GitEmptyState'; @@ -45,6 +47,7 @@ import { PullRequestSection } from './git/PullRequestSection'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; + type GitViewSnapshot = { directory?: string; selectedPaths: string[]; @@ -190,6 +193,7 @@ export const GitView: React.FC = () => { ? worktreeMap.get(currentSessionId) ?? undefined : undefined; + const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } = useGitIdentitiesStore(); @@ -260,6 +264,20 @@ export const GitView: React.FC = () => { const [generatedHighlights, setGeneratedHighlights] = React.useState( initialSnapshot?.generatedHighlights ?? [] ); + + const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null; + const sourceBranchForIntegrate = status?.current || null; + const defaultTargetBranch = React.useMemo(() => { + const fromMeta = worktreeMetadata?.createdFromBranch; + if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) { + return fromMeta.trim(); + } + const fromProject = activeProject?.worktreeDefaults?.baseBranch; + if (typeof fromProject === 'string' && fromProject.trim().length > 0) { + return fromProject.trim(); + } + return 'main'; + }, [worktreeMetadata?.createdFromBranch, activeProject?.worktreeDefaults?.baseBranch]); const clearGeneratedHighlights = React.useCallback(() => { setGeneratedHighlights([]); }, []); @@ -1030,7 +1048,23 @@ export const GitView: React.FC = () => { )} - {currentDirectory && status?.current ? ( + {worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate ? ( + { + if (!currentDirectory) return; + fetchStatus(currentDirectory, git); + fetchBranches(currentDirectory, git); + fetchLog(currentDirectory, git, logMaxCountLocal); + }} + /> + ) : null} + + {currentDirectory && status?.current && status?.tracking ? ( = ({ )} -
+
@@ -125,17 +127,18 @@ export const CommitSection: React.FC = ({ variant="outline" onClick={onCommit} disabled={!canCommit || isGeneratingMessage} - className="whitespace-nowrap" + className="commit-actions__btn whitespace-nowrap" + aria-label="Commit" > {commitAction === 'commit' ? ( <> - Committing... + Committing... ) : ( <> - Commit + Commit )} @@ -167,16 +170,18 @@ export const CommitSection: React.FC = ({ variant="default" onClick={onCommitAndPush} disabled={!canCommit || isGeneratingMessage} + className="commit-actions__btn" + aria-label="Commit & Push" > {commitAction === 'commitAndPush' ? ( <> - Pushing... + Pushing... ) : ( <> - Commit & Push + Commit & Push )} diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx new file mode 100644 index 00000000..9e1a762a --- /dev/null +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -0,0 +1,482 @@ +import * as React from 'react'; +import { RiArrowDownSLine, RiLoader4Line, RiSplitCellsHorizontal } from '@remixicon/react'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { toast } from '@/components/ui'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useMessageStore } from '@/stores/messageStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { execCommand } from '@/lib/execCommands'; +import { + abortIntegrate, + computeIntegratePlan, + continueIntegrate, + integrateWorktreeCommits, + getIntegrateConflictDetails, + isCherryPickInProgress, + type IntegrateConflictDetails, + type IntegrateInProgress, + type IntegratePlan, +} from '@/lib/git/integrateWorktreeCommits'; +import type { WorktreeMetadata } from '@/types/worktree'; + +type IntegrateUiState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ready'; plan: IntegratePlan } + | { kind: 'running'; plan: IntegratePlan } + | { kind: 'conflict'; state: IntegrateInProgress; details: IntegrateConflictDetails }; + +export const IntegrateCommitsSection: React.FC<{ + repoRoot: string; + sourceBranch: string; + worktreeMetadata: WorktreeMetadata; + localBranches: string[]; + defaultTargetBranch: string; + onRefresh?: () => void; +}> = ({ + repoRoot, + sourceBranch, + worktreeMetadata, + localBranches, + defaultTargetBranch, + onRefresh, +}) => { + const currentSessionId = useSessionStore((s) => s.currentSessionId); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const [isOpen, setIsOpen] = React.useState(true); + + const [targetBranch, setTargetBranch] = React.useState(defaultTargetBranch); + React.useEffect(() => { + setTargetBranch(defaultTargetBranch); + }, [defaultTargetBranch]); + + const isEligible = Boolean( + repoRoot && sourceBranch && targetBranch && targetBranch !== 'HEAD' && sourceBranch !== targetBranch + ); + + const [ui, setUi] = React.useState({ kind: 'idle' }); + const [showAllCommits, setShowAllCommits] = React.useState(false); + const [commitSummaries, setCommitSummaries] = React.useState>([]); + + const conflictStorageKey = React.useMemo(() => { + if (!currentSessionId) return null; + return `openchamber.integrate.conflict:${currentSessionId}`; + }, [currentSessionId]); + + React.useEffect(() => { + if (!conflictStorageKey || typeof window === 'undefined') return; + const raw = window.localStorage.getItem(conflictStorageKey); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as IntegrateInProgress; + if (!parsed?.tempWorktreePath || parsed.repoRoot !== repoRoot) { + window.localStorage.removeItem(conflictStorageKey); + return; + } + void (async () => { + const ok = await isCherryPickInProgress(parsed.tempWorktreePath).catch(() => false); + if (!ok) { + window.localStorage.removeItem(conflictStorageKey); + return; + } + const details = await getIntegrateConflictDetails(parsed.tempWorktreePath).catch(() => null); + if (!details) { + return; + } + setUi({ kind: 'conflict', state: parsed, details }); + })(); + } catch { + window.localStorage.removeItem(conflictStorageKey); + } + }, [conflictStorageKey, repoRoot]); + + React.useEffect(() => { + if (!isEligible) { + setUi({ kind: 'idle' }); + return; + } + let cancelled = false; + setUi({ kind: 'loading' }); + void (async () => { + try { + const plan = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }); + if (cancelled) return; + setUi({ kind: 'ready', plan }); + + // Preload commit subjects for preview. + if (plan.commits.length > 0) { + const max = 50; + // Show newest -> oldest. + const subset = plan.commits.slice(-max).reverse(); + const quoted = subset.map((s) => JSON.stringify(s)).join(' '); + const result = await execCommand( + `git show -s --format=%H%x09%h%x09%s ${quoted}`, + repoRoot + ); + const lines = (result.stdout || '').split(/\r?\n/).filter(Boolean); + const parsed: Array<{ sha: string; short: string; subject: string }> = []; + for (const line of lines) { + const [sha, short, subject] = line.split('\t'); + if (!sha || !short) continue; + parsed.push({ sha, short, subject: subject || '' }); + } + if (!cancelled) { + setCommitSummaries(parsed); + setShowAllCommits(false); + } + } else { + if (!cancelled) { + setCommitSummaries([]); + setShowAllCommits(false); + } + } + } catch { + if (!cancelled) setUi({ kind: 'idle' }); + } + })(); + return () => { + cancelled = true; + }; + }, [isEligible, repoRoot, sourceBranch, targetBranch]); + + const persistTarget = React.useCallback( + (branch: string) => { + if (!currentSessionId) return; + useSessionStore.getState().setWorktreeMetadata(currentSessionId, { + ...worktreeMetadata, + createdFromBranch: branch, + }); + }, + [currentSessionId, worktreeMetadata] + ); + + const handleResolveWithAi = React.useCallback(async (payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => { + setActiveMainTab('chat'); + if (!currentSessionId) { + toast.error('No active session', { description: 'Open a chat session first.' }); + return; + } + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); + const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const providerID = currentProviderId || lastUsedProvider?.providerID; + const modelID = currentModelId || lastUsedProvider?.modelID; + if (!providerID || !modelID) { + toast.error('No model selected'); + return; + } + + const visibleText = `Resolve cherry-pick conflicts and keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}. After edits, report if I can continue process.`; + const instructionsText = `Worktree commit integration is in progress. +- Repo root: ${payload.state.repoRoot} +- Temp target worktree: ${payload.state.tempWorktreePath} +- Source branch: ${payload.state.sourceBranch} +- Target branch: ${payload.state.targetBranch} + +Goal: +- Resolve conflicts inside the temp target worktree directory. +- Do NOT change intent of the commit being applied. +- After edits, say whether I can click "Continue". +`; + const payloadText = `Cherry-pick conflict context (JSON)\n${JSON.stringify({ + repoRoot: payload.state.repoRoot, + tempWorktreePath: payload.state.tempWorktreePath, + sourceBranch: payload.state.sourceBranch, + targetBranch: payload.state.targetBranch, + currentCommit: payload.state.currentCommit, + remainingCommits: payload.state.remainingCommits, + statusPorcelain: payload.details.statusPorcelain, + unmergedFiles: payload.details.unmergedFiles, + currentPatchMeta: payload.details.currentPatchMeta, + currentPatch: payload.details.currentPatch, + diff: payload.details.diff, + }, null, 2)}`; + + void useMessageStore.getState().sendMessage( + visibleText, + providerID, + modelID, + currentAgentName ?? undefined, + currentSessionId, + undefined, + null, + [ + { text: instructionsText, synthetic: true }, + { text: payloadText, synthetic: true }, + ], + currentVariant + ).catch((e) => { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to send message', { description: message }); + }); + }, [currentSessionId, setActiveMainTab]); + + const handleMove = React.useCallback(async () => { + if (ui.kind !== 'ready') return; + if (ui.plan.commits.length === 0) { + toast.message('No commits to move'); + return; + } + setUi({ kind: 'running', plan: ui.plan }); + try { + const result = await integrateWorktreeCommits(ui.plan); + if (result.kind === 'success') { + toast.success('Commits moved', { + description: `${result.moved} commit${result.moved === 1 ? '' : 's'} into ${ui.plan.targetBranch}`, + }); + const next = await computeIntegratePlan(ui.plan); + setUi({ kind: 'ready', plan: next }); + onRefresh?.(); + return; + } + if (result.kind === 'conflict') { + toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then Continue.' }); + setUi({ kind: 'conflict', state: result.state, details: result.details }); + if (conflictStorageKey && typeof window !== 'undefined') { + window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state)); + } + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to move commits', { description: message }); + const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null); + if (next) setUi({ kind: 'ready', plan: next }); + else setUi({ kind: 'idle' }); + } + }, [ui, onRefresh, repoRoot, sourceBranch, targetBranch, conflictStorageKey]); + + const handleAbort = React.useCallback(async () => { + if (ui.kind !== 'conflict') return; + try { + await abortIntegrate(ui.state); + toast.message('Cherry-pick aborted'); + if (conflictStorageKey && typeof window !== 'undefined') { + window.localStorage.removeItem(conflictStorageKey); + } + } finally { + const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null); + if (next) setUi({ kind: 'ready', plan: next }); + else setUi({ kind: 'idle' }); + } + }, [ui, repoRoot, sourceBranch, targetBranch, conflictStorageKey]); + + const handleContinue = React.useCallback(async () => { + if (ui.kind !== 'conflict') return; + try { + const result = await continueIntegrate(ui.state); + if (result.kind === 'success') { + toast.success('Cherry-pick finished'); + const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null); + if (next) setUi({ kind: 'ready', plan: next }); + else setUi({ kind: 'idle' }); + if (conflictStorageKey && typeof window !== 'undefined') { + window.localStorage.removeItem(conflictStorageKey); + } + onRefresh?.(); + return; + } + if (result.kind === 'conflict') { + setUi({ kind: 'conflict', state: result.state, details: result.details }); + if (conflictStorageKey && typeof window !== 'undefined') { + window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state)); + } + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Cherry-pick continue failed', { description: message }); + } + }, [ui, repoRoot, sourceBranch, targetBranch, onRefresh, conflictStorageKey]); + + if (!repoRoot || !sourceBranch) { + return null; + } + + return ( + + +
+ +

Re-integrate commits

+ {ui.kind === 'ready' && ui.plan.commits.length > 0 ? ( + {ui.plan.commits.length} to move + ) : null} +
+
+ {ui.kind === 'loading' || ui.kind === 'running' ? ( + + ) : null} +
+
+ + +
+
+
+
+
Move commits
+
+ {sourceBranch} → {targetBranch} +
+
+ +
+ + + + + + + + + + No branches found. + + {localBranches.map((branch) => ( + { + setTargetBranch(branch); + persistTarget(branch); + }} + > + {branch} + + ))} + + + + + + + {ui.kind === 'ready' ? ( + + ) : ui.kind === 'loading' ? ( + + ) : ui.kind === 'running' ? ( + + ) : null} +
+ + {ui.kind === 'ready' && ui.plan.commits.length === 0 && ( +
No commits to move.
+ )} + + {ui.kind === 'ready' && ui.plan.commits.length > 0 && ( +
+
+
+ Commits to move + ({ui.plan.commits.length}) +
+ {commitSummaries.length > 0 && ui.plan.commits.length > 5 && ( + + )} +
+ +
+ {(showAllCommits ? commitSummaries : commitSummaries.slice(0, 5)).map((c) => ( +
+ {c.short} + {c.subject || c.sha} +
+ ))} + {commitSummaries.length === 0 && ( +
Preview unavailable.
+ )} + {ui.plan.commits.length > commitSummaries.length && ( +
+ Showing first {commitSummaries.length} commits. +
+ )} +
+
+ )} + + {ui.kind === 'conflict' && ( +
+
+ Conflicts in {ui.details.unmergedFiles.length} files +
+
+ Current commit: {ui.state.currentCommit.slice(0, 7)} +
+
+ {ui.details.unmergedFiles.slice(0, 6).map((file) => ( + + {file} + + ))} + {ui.details.unmergedFiles.length > 6 && ( + +{ui.details.unmergedFiles.length - 6} more + )} +
+
+ + + +
+
+ )} +
+
+ + + ); +}; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 9f301dc5..498ac9f1 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -499,6 +499,23 @@ html:not(.dark) .chat-scroll { } } +/* Commit actions: collapse labels when narrow. */ +@container commit-actions (max-width: 28rem) { + .commit-actions__label--long { + display: none; + } +} + +@container commit-actions (max-width: 22rem) { + .commit-actions__label { + display: none; + } + + .commit-actions__btn { + padding-inline: 0.5rem; + } +} + /* Text font: IBM Plex Sans */ .streamdown-content { font-family: var(--font-sans); diff --git a/packages/ui/src/lib/execCommands.ts b/packages/ui/src/lib/execCommands.ts new file mode 100644 index 00000000..e6bf19ce --- /dev/null +++ b/packages/ui/src/lib/execCommands.ts @@ -0,0 +1,57 @@ +import type { CommandExecResult, FilesAPI, RuntimeAPIs } from '@/lib/api/types'; + +type ExecResult = { success: boolean; results: CommandExecResult[] }; + +const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api'; + +const getBaseUrl = (): string => { + if (typeof DEFAULT_BASE_URL === 'string' && DEFAULT_BASE_URL.startsWith('/')) { + return DEFAULT_BASE_URL; + } + return DEFAULT_BASE_URL; +}; + +function getRuntimeFilesAPI(): FilesAPI | null { + if (typeof window === 'undefined') return null; + const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__; + if (apis?.files) { + return apis.files; + } + return null; +} + +export async function execCommands(commands: string[], cwd: string): Promise { + const runtimeFiles = getRuntimeFilesAPI(); + if (runtimeFiles?.execCommands) { + return runtimeFiles.execCommands(commands, cwd); + } + + const response = await fetch(`${getBaseUrl()}/fs/exec`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ commands, cwd, background: false }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || 'Command exec failed'); + } + + const payload = (await response.json().catch(() => null)) as + | { success?: boolean; results?: CommandExecResult[] } + | null; + + return { + success: Boolean(payload?.success), + results: Array.isArray(payload?.results) ? payload!.results! : [], + }; +} + +export async function execCommand(command: string, cwd: string): Promise { + const result = await execCommands([command], cwd); + const first = result.results[0]; + if (!first) { + return { command, success: result.success }; + } + return first; +} diff --git a/packages/ui/src/lib/git/integrateWorktreeCommits.ts b/packages/ui/src/lib/git/integrateWorktreeCommits.ts new file mode 100644 index 00000000..e72c6204 --- /dev/null +++ b/packages/ui/src/lib/git/integrateWorktreeCommits.ts @@ -0,0 +1,293 @@ +import type { CommandExecResult } from '@/lib/api/types'; +import { execCommand } from '@/lib/execCommands'; + +export type IntegratePlan = { + repoRoot: string; + sourceBranch: string; + targetBranch: string; + commits: string[]; +}; + +export type IntegrateConflictDetails = { + statusPorcelain: string; + unmergedFiles: string[]; + diff: string; + currentPatchMeta: string; + currentPatch: string; +}; + +export type IntegrateInProgress = { + repoRoot: string; + tempWorktreePath: string; + sourceBranch: string; + targetBranch: string; + remainingCommits: string[]; + currentCommit: string; +}; + +export type IntegrateResult = + | { kind: 'noop'; reason: string } + | { kind: 'success'; moved: number } + | { kind: 'conflict'; state: IntegrateInProgress; details: IntegrateConflictDetails }; + +const shellQuote = (value: string): string => { + const v = value.trim(); + if (!v) return "''"; + return `'${v.replace(/'/g, `'\\''`)}'`; +}; + +const trimLines = (value: string | undefined): string[] => + (value || '') + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + +const isOk = (result: CommandExecResult): boolean => Boolean(result.success); + +const stdoutText = (result: CommandExecResult): string => (result.stdout || '').trim(); +const stderrText = (result: CommandExecResult): string => (result.stderr || '').trim(); + +async function ensureLocalBranch(repoRoot: string, candidate: string): Promise { + const raw = candidate.trim(); + if (!raw || raw === 'HEAD') { + return 'HEAD'; + } + + const hasLocal = await execCommand( + `git show-ref --verify --quiet ${shellQuote(`refs/heads/${raw}`)} && echo ok || echo missing`, + repoRoot + ); + if (stdoutText(hasLocal) === 'ok') { + return raw; + } + + // remotes/origin/main -> main (track origin/main) + if (raw.startsWith('remotes/')) { + const remoteRef = raw.slice('remotes/'.length); + const parts = remoteRef.split('/'); + const remote = parts[0] || 'origin'; + const name = parts.slice(1).join('/'); + if (name) { + await execCommand(`git branch --track ${shellQuote(name)} ${shellQuote(`${remote}/${name}`)}`, repoRoot); + return name; + } + } + + // Try origin/ + const remoteCheck = await execCommand( + `git show-ref --verify --quiet ${shellQuote(`refs/remotes/origin/${raw}`)} && echo ok || echo missing`, + repoRoot + ); + if (stdoutText(remoteCheck) === 'ok') { + await execCommand(`git branch --track ${shellQuote(raw)} ${shellQuote(`origin/${raw}`)}`, repoRoot); + return raw; + } + + return raw; +} + +export async function computeIntegratePlan(args: { + repoRoot: string; + sourceBranch: string; + targetBranch: string; +}): Promise { + const repoRoot = args.repoRoot; + const sourceBranch = args.sourceBranch.trim(); + const targetBranchRaw = args.targetBranch.trim(); + if (!sourceBranch || !targetBranchRaw) { + return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] }; + } + + const targetBranch = await ensureLocalBranch(repoRoot, targetBranchRaw); + + const cherry = await execCommand(`git cherry ${shellQuote(targetBranch)} ${shellQuote(sourceBranch)}`, repoRoot); + const cherryLines = trimLines(cherry.stdout); + const plus = new Set(); + for (const line of cherryLines) { + const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i); + if (match) { + plus.add(match[1]); + } + } + + const revList = await execCommand( + `git rev-list --reverse ${shellQuote(`${targetBranch}..${sourceBranch}`)}`, + repoRoot + ); + const ordered = trimLines(revList.stdout); + const commits = ordered.filter((sha) => plus.has(sha)); + + return { repoRoot, sourceBranch, targetBranch, commits }; +} + +async function createTempWorktree(repoRoot: string, targetBranch: string): Promise { + const tmp = await execCommand( + 'mkdir -p "$HOME/.config/openchamber/tmp" && mktemp -d "$HOME/.config/openchamber/tmp/oc-integrate-XXXXXX"', + repoRoot + ); + const tmpDir = stdoutText(tmp); + if (!tmpDir) { + throw new Error(stderrText(tmp) || 'Failed to create temp directory'); + } + const add = await execCommand( + `git worktree add --force ${shellQuote(tmpDir)} ${shellQuote(targetBranch)}`, + repoRoot + ); + if (!isOk(add)) { + throw new Error(stderrText(add) || 'Failed to create temp worktree'); + } + return tmpDir; +} + +async function removeTempWorktree(repoRoot: string, tmpDir: string): Promise { + await execCommand(`git worktree remove --force ${shellQuote(tmpDir)}`, repoRoot).catch(() => undefined); + await execCommand('git worktree prune', repoRoot).catch(() => undefined); +} + +async function maybeFastForwardUpstream(tmpDir: string): Promise { + const upstream = await execCommand('git rev-parse --abbrev-ref --symbolic-full-name @{u}', tmpDir); + const upstreamRef = stdoutText(upstream); + if (!upstreamRef) { + return; + } + await execCommand('git fetch', tmpDir); + const ff = await execCommand(`git merge --ff-only ${shellQuote(upstreamRef)}`, tmpDir); + if (!isOk(ff)) { + throw new Error(stderrText(ff) || 'Fast-forward failed'); + } +} + +async function collectConflictDetails(tmpDir: string): Promise { + const status = await execCommand('git status --porcelain', tmpDir); + const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir); + const diff = await execCommand('git diff', tmpDir); + const meta = await execCommand('git show --no-patch --pretty=fuller CHERRY_PICK_HEAD', tmpDir); + const patch = await execCommand('git show CHERRY_PICK_HEAD', tmpDir); + + return { + statusPorcelain: status.stdout || '', + unmergedFiles: trimLines(unmerged.stdout), + diff: diff.stdout || diff.stderr || '', + currentPatchMeta: meta.stdout || meta.stderr || '', + currentPatch: patch.stdout || patch.stderr || '', + }; +} + +export async function getIntegrateConflictDetails(tmpDir: string): Promise { + return collectConflictDetails(tmpDir); +} + +export async function isCherryPickInProgress(tmpDir: string): Promise { + const head = await execCommand('git rev-parse --verify --quiet CHERRY_PICK_HEAD && echo yes || echo no', tmpDir); + return stdoutText(head) === 'yes'; +} + +export async function integrateWorktreeCommits(plan: IntegratePlan): Promise { + if (plan.commits.length === 0) { + return { kind: 'noop', reason: 'No commits to move' }; + } + + const tmpDir = await createTempWorktree(plan.repoRoot, plan.targetBranch); + + let remaining: string[] = []; + try { + await maybeFastForwardUpstream(tmpDir); + + const clean = await execCommand('git status --porcelain', tmpDir); + if (stdoutText(clean)) { + throw new Error('Target branch has local changes; abort integration and retry'); + } + + remaining = [...plan.commits]; + while (remaining.length > 0) { + const sha = remaining[0]; + const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir); + if (isOk(pick)) { + remaining.shift(); + continue; + } + + const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir); + const unmergedFiles = trimLines(unmerged.stdout); + if (unmergedFiles.length > 0) { + const details = await collectConflictDetails(tmpDir); + return { + kind: 'conflict', + state: { + repoRoot: plan.repoRoot, + tempWorktreePath: tmpDir, + sourceBranch: plan.sourceBranch, + targetBranch: plan.targetBranch, + remainingCommits: remaining, + currentCommit: sha, + }, + details, + }; + } + + throw new Error(stderrText(pick) || 'Cherry-pick failed'); + } + + await removeTempWorktree(plan.repoRoot, tmpDir); + return { kind: 'success', moved: plan.commits.length }; + } catch (e) { + // Cleanup on any non-conflict error. + await removeTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined); + throw e; + } +} + +export async function abortIntegrate(state: IntegrateInProgress): Promise { + await execCommand('git cherry-pick --abort', state.tempWorktreePath).catch(() => undefined); + await removeTempWorktree(state.repoRoot, state.tempWorktreePath); +} + +export async function continueIntegrate(state: IntegrateInProgress): Promise { + const cont = await execCommand('git cherry-pick --continue', state.tempWorktreePath); + if (!isOk(cont)) { + const unmerged = await execCommand('git diff --name-only --diff-filter=U', state.tempWorktreePath); + const unmergedFiles = trimLines(unmerged.stdout); + if (unmergedFiles.length > 0) { + const details = await collectConflictDetails(state.tempWorktreePath); + return { kind: 'conflict', state, details }; + } + throw new Error(stderrText(cont) || 'Cherry-pick continue failed'); + } + + const tmpDir = state.tempWorktreePath; + const remaining = [...state.remainingCommits]; + if (remaining.length > 0 && remaining[0] === state.currentCommit) { + remaining.shift(); + } + + const still = [...remaining]; + while (still.length > 0) { + const sha = still[0]; + const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir); + if (isOk(pick)) { + still.shift(); + continue; + } + const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir); + const unmergedFiles = trimLines(unmerged.stdout); + if (unmergedFiles.length > 0) { + const details = await collectConflictDetails(tmpDir); + return { + kind: 'conflict', + state: { + repoRoot: state.repoRoot, + tempWorktreePath: tmpDir, + sourceBranch: state.sourceBranch, + targetBranch: state.targetBranch, + remainingCommits: still, + currentCommit: sha, + }, + details, + }; + } + throw new Error(stderrText(pick) || 'Cherry-pick failed'); + } + + await removeTempWorktree(state.repoRoot, state.tempWorktreePath); + return { kind: 'success', moved: remaining.length }; +} diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index c76e07fb..8785d9f7 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -11,6 +11,7 @@ import { isVSCodeRuntime } from './desktop'; type ProjectRef = { id: string; path: string }; const CONFIG_FILENAME = 'openchamber.json'; +// LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo. const LEGACY_CONFIG_DIR = '.openchamber'; const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber']; const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects']; diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 71ce39bf..d65ed1d6 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -96,9 +96,14 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> { startPoint, }); + const createdMetadata = { + ...metadata, + createdFromBranch: startPoint ?? 'HEAD', + }; + // Get worktree status const status = await getWorktreeStatus(metadata.path).catch(() => undefined); - const createdMetadata = status ? { ...metadata, status } : metadata; + const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata; // Create the session const sessionStore = useSessionStore.getState(); @@ -117,7 +122,7 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> { const agents = configState.agents; sessionStore.initializeNewOpenChamberSession(session.id, agents); sessionStore.setSessionDirectory(session.id, metadata.path); - sessionStore.setWorktreeMetadata(session.id, createdMetadata); + sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus); // Apply default agent and model settings try { @@ -263,9 +268,14 @@ export async function createWorktreeSessionForBranch( startPoint: branchName, }); + const createdMetadata = { + ...metadata, + createdFromBranch: branchName, + }; + // Get worktree status const status = await getWorktreeStatus(metadata.path).catch(() => undefined); - const createdMetadata = status ? { ...metadata, status } : metadata; + const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata; // Create the session const sessionStore = useSessionStore.getState(); @@ -284,7 +294,7 @@ export async function createWorktreeSessionForBranch( const agents = configState.agents; sessionStore.initializeNewOpenChamberSession(session.id, agents); sessionStore.setSessionDirectory(session.id, metadata.path); - sessionStore.setWorktreeMetadata(session.id, createdMetadata); + sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus); // Apply default agent and model settings try { @@ -431,8 +441,13 @@ export async function createWorktreeSessionForNewBranch( allowSuffix, }); + const createdMetadata = { + ...metadata, + createdFromBranch: start, + }; + const status = await getWorktreeStatus(metadata.path).catch(() => undefined); - const createdMetadata = status ? { ...metadata, status } : metadata; + const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata; const sessionStore = useSessionStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); @@ -444,7 +459,7 @@ export async function createWorktreeSessionForNewBranch( const configState = useConfigStore.getState(); sessionStore.initializeNewOpenChamberSession(session.id, configState.agents); sessionStore.setSessionDirectory(session.id, metadata.path); - sessionStore.setWorktreeMetadata(session.id, createdMetadata); + sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus); // Apply default agent/model/variant settings (reuse same logic as createWorktreeSessionForBranch) try { diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index 30b88509..5e6ea550 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -574,7 +574,7 @@ export const useSessionStore = create()( } }); - // Check if .openchamber directory exists before trying to list it + // LEGACY_WORKTREES: check if .openchamber directory exists before listing it // LEGACY_WORKTREES: filesystem scan fallback for legacy /.openchamber/* const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject); const worktreeDirExists = projectEntriesList.some( diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index 2c51eaaf..b00af151 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -9,6 +9,7 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService'; import type { Session } from '@opencode-ai/sdk/v2'; +// LEGACY_WORKTREES: legacy worktree root inside project. const OPENCHAMBER_DIR = '.openchamber'; const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => { diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index ce7ebc31..35f928d2 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -167,6 +167,11 @@ export const useMultiRunStore = create()( startPoint: startPoint ?? null, }); + const enrichedMetadata = { + ...worktreeMetadata, + createdFromBranch: startPoint ?? 'HEAD', + }; + // Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates) const sessionTitle = count > 1 ? `${groupSlug}/${model.providerID}/${model.modelID}/${index}` @@ -177,7 +182,7 @@ export const useMultiRunStore = create()( () => opencodeClient.createSession({ title: sessionTitle }) ); - useSessionStore.getState().setWorktreeMetadata(session.id, worktreeMetadata); + useSessionStore.getState().setWorktreeMetadata(session.id, enrichedMetadata); createdRuns.push({ sessionId: session.id, diff --git a/packages/ui/src/types/worktree.ts b/packages/ui/src/types/worktree.ts index 5e6eaae3..87646a61 100644 --- a/packages/ui/src/types/worktree.ts +++ b/packages/ui/src/types/worktree.ts @@ -18,6 +18,12 @@ export interface WorktreeMetadata { /** SDK worktree name (slug), if available. */ name?: string; + /** + * Branch/ref this worktree was created from (intended integration target). + * For SDK worktrees this is typically the user-selected base branch. + */ + createdFromBranch?: string; + relativePath?: string; status?: {