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?: {