Feat: add push to and pull from git with remote selection, along with rebase and merge options (#345)
* Add getRemotes API endpoint
- Add getRemotes() function to git-service.js using simple-git's getRemotes(true)
- Returns array of {name, fetchUrl, pushUrl} for each remote
- Add GET /api/git/remotes endpoint to server/index.js
- Follows existing patterns for git endpoints (directory query param, error handling)
* Add merge and rebase API endpoints
- Add rebase(), abortRebase(), merge(), abortMerge() to git-service.js
- Add POST /api/git/rebase, /api/git/rebase/abort endpoints
- Add POST /api/git/merge, /api/git/merge/abort endpoints
- All functions return { success, conflict?, conflictFiles? }
- Conflict detection via error message parsing and git status
* Add client API functions for git remotes, merge, and rebase
- Added GitRemote, GitMergeResult, GitRebaseResult interfaces to types.ts
- Added getRemotes(), rebase(), abortRebase(), merge(), abortMerge() to gitApiHttp.ts
- Added corresponding exports and runtime wrappers to gitApi.ts
- All functions follow existing patterns with proper error handling
- Lint and type-check pass
* feat(git): add remote selection dropdown to SyncActions
- Add remotes prop to SyncActions component
- Change callbacks to accept GitRemote parameter
- Show dropdown menu when multiple remotes exist
- Execute immediately for single remote repos
- Display remote name and fetch URL in dropdown items
* feat: add BranchIntegrationSection component
- Branch selector dropdown (local + remote branches)
- Merge and Rebase buttons with loading states
- Props: currentBranch, localBranches, remoteBranches, onMerge, onRebase, disabled, isOperating
- Follows existing UI patterns (Command + DropdownMenu)
- Tooltips for all interactive elements
* Add ConflictDialog component for merge/rebase conflicts
- Shows when merge/rebase returns conflict
- Three action options: Resolve in New Session, Abort, Continue Later
- Resolve in New Session opens OpenChamber session in conflict directory
- Displays list of conflicted files
- Uses theme tokens for colors
- Follows existing dialog patterns from AboutDialog.tsx
* Integrate git remote selection and branch operations into GitView
- Fetch remotes on mount and store in state
- Pass remotes to SyncActions and update handleSyncAction to accept GitRemote parameter
- Add BranchIntegrationSection component below sync actions for merge/rebase operations
- Add ConflictDialog to handle merge/rebase conflicts with option to resolve in new session
- Export BranchIntegrationSection and ConflictDialog from git/index.ts
- Update GitHeader to accept remotes prop and pass to SyncActions
- Handle single vs multiple remote scenarios (immediate action vs dropdown)
- Fix React hooks exhaustive-deps warnings by capturing status in local variable
* fix: add missing git API methods to web and vscode packages
* feat: extend VSCode bridge with git remote/rebase/merge endpoints
* feat: add stash support for git operations across UI and API
* hive(01-add-types-for-conflict-details): Added MergeConflictDetails interface to packages/u
* hive(02-add-server-side-conflict-details-function): Added `getConflictDetails(directory)` function to
* hive(03-add-server-endpoint-for-conflict-details): Added GET /api/git/conflict-details endpoint to pa
* hive(04-add-client-side-api-for-conflict-details): Added client-side API for conflict details:
1. **
* hive(05-enhance-conflictdialog-with-rich-context): Enhanced ConflictDialog to fetch and use rich conf
* hive(06-add-state-persistence-for-conflicts): Added state persistence for merge/rebase conflicts
* feat: add conflict details API and AI resolve flow
* fix: improve focus handling in git UI and adjust web dev server port
* feat: add continue merge/rebase support and logs
* fix: address bugs in git merge/rebase feature
- Add explicit parentheses to hasUnresolvedConflicts logic for clarity
- Add error handling for stash operation in handleStashAndRetry
- Fix SSH key path escaping on Windows by normalizing before validation
* fix: add default value for remotes prop to prevent crash
When remotes is undefined, accessing .length throws TypeError.
Add default empty array to handle undefined case gracefully.
* fix: replace DialogFooter with plain div for proper button layout
DialogFooter's default flex-col-reverse and sm:flex-row styles
were conflicting with the intended vertical button stack layout,
causing buttons to not display properly.
* Fix lint erorr
* fix: remove duplicate BranchIntegrationSection and fix broken vscode bridge
- Remove duplicate BranchIntegrationSection from GitView.tsx (already in GitHeader)
- Fix vscode bridge calling non-existent ensureOpenChamberIgnored function
(legacy worktree function was removed, make api:git/ignore-openchamber a no-op)
* fix: handleResolveWithAIFromBanner now properly detects conflicts from status
The function was checking conflictFiles state which may be empty when
the banner is shown. Now it extracts conflict files directly from the
git status (files with 'U' status) and properly sets up the conflict
dialog state before opening it.
This commit is contained in:
@@ -243,6 +243,20 @@ export interface GitStatusFile {
|
||||
working_dir: string;
|
||||
}
|
||||
|
||||
export interface GitMergeInProgress {
|
||||
/** Short SHA of MERGE_HEAD */
|
||||
head: string;
|
||||
/** First line of MERGE_MSG */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitRebaseInProgress {
|
||||
/** Branch name being rebased */
|
||||
headName: string;
|
||||
/** Short SHA of the onto commit */
|
||||
onto: string;
|
||||
}
|
||||
|
||||
export interface GitStatusResult {
|
||||
current: string;
|
||||
tracking: string | null;
|
||||
@@ -251,6 +265,10 @@ export interface GitStatusResult {
|
||||
files: GitStatusFile[];
|
||||
isClean: boolean;
|
||||
diffStats?: Record<string, { insertions: number; deletions: number }>;
|
||||
/** Present when a merge is in progress with conflicts */
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
/** Present when a rebase is in progress */
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,6 +341,9 @@ export async function getGitStatus(directory: string): Promise<GitStatusResult>
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-progress operations
|
||||
const inProgressState = await checkInProgressOperations(directory);
|
||||
|
||||
return {
|
||||
current: head?.name || '',
|
||||
tracking: head?.upstream ? `${head.upstream.remote}/${head.upstream.name}` : null,
|
||||
@@ -330,9 +351,73 @@ export async function getGitStatus(directory: string): Promise<GitStatusResult>
|
||||
behind: head?.behind || 0,
|
||||
files,
|
||||
isClean: files.length === 0,
|
||||
...inProgressState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for in-progress merge/rebase operations
|
||||
*/
|
||||
async function checkInProgressOperations(directory: string): Promise<{
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
}> {
|
||||
const result: {
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
} = {};
|
||||
|
||||
const gitDir = path.join(directory, '.git');
|
||||
|
||||
try {
|
||||
// Check MERGE_HEAD for merge in progress
|
||||
const mergeHeadPath = path.join(gitDir, 'MERGE_HEAD');
|
||||
const mergeHeadExists = await fs.promises.stat(mergeHeadPath).then(() => true).catch(() => false);
|
||||
|
||||
if (mergeHeadExists) {
|
||||
const mergeHead = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => '');
|
||||
const headSha = mergeHead.trim().slice(0, 7);
|
||||
// Only set mergeInProgress if we actually have a valid head SHA
|
||||
if (headSha) {
|
||||
const mergeMsg = await fs.promises.readFile(path.join(gitDir, 'MERGE_MSG'), 'utf8').catch(() => '');
|
||||
result.mergeInProgress = {
|
||||
head: headSha,
|
||||
message: mergeMsg.split('\n')[0] || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
|
||||
const rebaseMergeExists = await fs.promises.stat(path.join(gitDir, 'rebase-merge')).then(() => true).catch(() => false);
|
||||
const rebaseApplyExists = await fs.promises.stat(path.join(gitDir, 'rebase-apply')).then(() => true).catch(() => false);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) {
|
||||
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
|
||||
const headName = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'head-name'), 'utf8').catch(() => '');
|
||||
const onto = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'onto'), 'utf8').catch(() => '');
|
||||
|
||||
const headNameTrimmed = headName.trim().replace('refs/heads/', '');
|
||||
const ontoTrimmed = onto.trim().slice(0, 7);
|
||||
|
||||
// Only set rebaseInProgress if we have valid data
|
||||
if (headNameTrimmed || ontoTrimmed) {
|
||||
result.rebaseInProgress = {
|
||||
headName: headNameTrimmed,
|
||||
onto: ontoTrimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Get git status using raw git commands
|
||||
*/
|
||||
@@ -383,6 +468,9 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-progress operations
|
||||
const inProgressState = await checkInProgressOperations(directory);
|
||||
|
||||
return {
|
||||
current,
|
||||
tracking,
|
||||
@@ -390,6 +478,7 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
||||
behind,
|
||||
files,
|
||||
isClean: files.length === 0,
|
||||
...inProgressState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1319,3 +1408,227 @@ export async function setGitIdentity(
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============== Remote Operations ==============
|
||||
|
||||
export interface GitRemote {
|
||||
name: string;
|
||||
fetchUrl: string;
|
||||
pushUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of remotes
|
||||
*/
|
||||
export async function getRemotes(directory: string): Promise<GitRemote[]> {
|
||||
const result = await execGit(['remote', '-v'], directory);
|
||||
if (result.exitCode !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const remoteMap = new Map<string, GitRemote>();
|
||||
const lines = result.stdout.split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
||||
if (match) {
|
||||
const [, name, url, type] = match;
|
||||
if (!remoteMap.has(name)) {
|
||||
remoteMap.set(name, { name, fetchUrl: '', pushUrl: '' });
|
||||
}
|
||||
const remote = remoteMap.get(name)!;
|
||||
if (type === 'fetch') {
|
||||
remote.fetchUrl = url;
|
||||
} else {
|
||||
remote.pushUrl = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(remoteMap.values());
|
||||
}
|
||||
|
||||
// ============== Merge & Rebase Operations ==============
|
||||
|
||||
export interface GitMergeResult {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface GitRebaseResult {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebase current branch onto target
|
||||
*/
|
||||
export async function rebase(
|
||||
directory: string,
|
||||
options: { onto: string }
|
||||
): Promise<GitRebaseResult> {
|
||||
const result = await execGit(['rebase', options.onto], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('could not apply') ||
|
||||
output.includes('merge conflict');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Rebase failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress rebase
|
||||
*/
|
||||
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['rebase', '--abort'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge branch into current
|
||||
*/
|
||||
export async function merge(
|
||||
directory: string,
|
||||
options: { branch: string }
|
||||
): Promise<GitMergeResult> {
|
||||
const result = await execGit(['merge', options.branch], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('merge conflict') ||
|
||||
output.includes('automatic merge failed');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Merge failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress merge
|
||||
*/
|
||||
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['merge', '--abort'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an in-progress rebase after conflicts are resolved
|
||||
*/
|
||||
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const result = await execGit(['rebase', '--continue'], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('needs merge') ||
|
||||
output.includes('unmerged');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Continue rebase failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an in-progress merge after conflicts are resolved
|
||||
*/
|
||||
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
// For merge, we commit after resolving conflicts
|
||||
const result = await execGit(['commit', '--no-edit'], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('needs merge') ||
|
||||
output.includes('unmerged');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Continue merge failed');
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
/**
|
||||
* Stash changes
|
||||
*/
|
||||
export async function stash(
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
const args = ['stash', 'push'];
|
||||
|
||||
// Include untracked files by default
|
||||
if (options?.includeUntracked !== false) {
|
||||
args.push('--include-untracked');
|
||||
}
|
||||
|
||||
if (options?.message) {
|
||||
args.push('-m', options.message);
|
||||
}
|
||||
|
||||
const result = await execGit(args, directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash
|
||||
*/
|
||||
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['stash', 'pop'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user