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
This commit is contained in:
@@ -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<ExecResult> {
|
||||
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<CommandExecResult> {
|
||||
const result = await execCommands([command], cwd);
|
||||
const first = result.results[0];
|
||||
if (!first) {
|
||||
return { command, success: result.success };
|
||||
}
|
||||
return first;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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/<raw>
|
||||
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<IntegratePlan> {
|
||||
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<string>();
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<IntegrateConflictDetails> {
|
||||
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<IntegrateConflictDetails> {
|
||||
return collectConflictDetails(tmpDir);
|
||||
}
|
||||
|
||||
export async function isCherryPickInProgress(tmpDir: string): Promise<boolean> {
|
||||
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<IntegrateResult> {
|
||||
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<void> {
|
||||
await execCommand('git cherry-pick --abort', state.tempWorktreePath).catch(() => undefined);
|
||||
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
}
|
||||
|
||||
export async function continueIntegrate(state: IntegrateInProgress): Promise<IntegrateResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user