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:
@@ -97,4 +97,4 @@
|
||||
"package.json",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7323,6 +7323,166 @@ Context:
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/remotes', async (req, res) => {
|
||||
const { getRemotes } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const remotes = await getRemotes(directory);
|
||||
res.json(remotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to get remotes:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get remotes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/rebase', async (req, res) => {
|
||||
const { rebase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await rebase(directory, req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to rebase:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to rebase' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/rebase/abort', async (req, res) => {
|
||||
const { abortRebase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await abortRebase(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to abort rebase:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to abort rebase' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/merge', async (req, res) => {
|
||||
const { merge } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await merge(directory, req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to merge:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to merge' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/merge/abort', async (req, res) => {
|
||||
const { abortMerge } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await abortMerge(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to abort merge:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to abort merge' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/rebase/continue', async (req, res) => {
|
||||
const { continueRebase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await continueRebase(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue rebase:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to continue rebase' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/merge/continue', async (req, res) => {
|
||||
const { continueMerge } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await continueMerge(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue merge:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to continue merge' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/conflict-details', async (req, res) => {
|
||||
const { getConflictDetails } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await getConflictDetails(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to get conflict details:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get conflict details' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/stash', async (req, res) => {
|
||||
const { stash } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await stash(directory, req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to stash:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to stash' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/stash/pop', async (req, res) => {
|
||||
const { stashPop } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await stashPop(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to pop stash:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to pop stash' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/commit', async (req, res) => {
|
||||
const { commit } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,54 @@ const fsp = fs.promises;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||
|
||||
/**
|
||||
* Escape an SSH key path for use in core.sshCommand.
|
||||
* Handles Windows/Unix differences and prevents command injection.
|
||||
*/
|
||||
function escapeSshKeyPath(sshKeyPath) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Normalize path first on Windows (convert backslashes to forward slashes)
|
||||
let normalizedPath = sshKeyPath;
|
||||
if (isWindows) {
|
||||
normalizedPath = sshKeyPath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Validate: reject paths with characters that could enable injection
|
||||
// Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
|
||||
// Note: backslash is not in this list since we've already normalized Windows paths
|
||||
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
|
||||
if (dangerousChars.test(normalizedPath)) {
|
||||
throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, Git (via MSYS/MinGW) expects Unix-style paths
|
||||
// Convert "C:/path" to "/c/path" for MSYS compatibility
|
||||
let unixPath = normalizedPath;
|
||||
const driveMatch = unixPath.match(/^([A-Za-z]):\//);
|
||||
if (driveMatch) {
|
||||
unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`;
|
||||
}
|
||||
|
||||
// Use single quotes for the path (prevents shell interpretation)
|
||||
return `'${unixPath}'`;
|
||||
} else {
|
||||
// On Unix, use single quotes and escape any single quotes in the path
|
||||
// Single quotes prevent all shell interpretation except for single quotes themselves
|
||||
const escaped = normalizedPath.replace(/'/g, "'\\''");
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSH command string for git config
|
||||
*/
|
||||
function buildSshCommand(sshKeyPath) {
|
||||
const escapedPath = escapeSshKeyPath(sshKeyPath);
|
||||
return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
|
||||
}
|
||||
|
||||
const isSocketPath = async (candidate) => {
|
||||
if (!candidate || typeof candidate !== 'string') {
|
||||
return false;
|
||||
@@ -221,7 +269,7 @@ export async function setLocalIdentity(directory, profile) {
|
||||
if (authType === 'ssh' && profile.sshKey) {
|
||||
await git.addConfig(
|
||||
'core.sshCommand',
|
||||
`ssh -i ${profile.sshKey}`,
|
||||
buildSshCommand(profile.sshKey),
|
||||
false,
|
||||
'local'
|
||||
);
|
||||
@@ -404,6 +452,58 @@ export async function getStatus(directory) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-progress operations
|
||||
let mergeInProgress = null;
|
||||
let rebaseInProgress = null;
|
||||
|
||||
try {
|
||||
// Check MERGE_HEAD for merge in progress
|
||||
const mergeHeadExists = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (mergeHeadExists) {
|
||||
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
||||
const headSha = mergeHead.trim().slice(0, 7);
|
||||
// Only set mergeInProgress if we actually have a valid head SHA
|
||||
if (headSha) {
|
||||
const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
|
||||
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 fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
|
||||
const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) {
|
||||
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
|
||||
const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => '');
|
||||
const onto = await fsp.readFile(path.join(directoryPath, '.git', 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) {
|
||||
rebaseInProgress = {
|
||||
headName: headNameTrimmed,
|
||||
onto: ontoTrimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
current: status.current,
|
||||
tracking,
|
||||
@@ -416,6 +516,8 @@ export async function getStatus(directory) {
|
||||
})),
|
||||
isClean: status.isClean(),
|
||||
diffStats,
|
||||
mergeInProgress,
|
||||
rebaseInProgress,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
@@ -1201,3 +1303,302 @@ export async function renameBranch(directory, oldName, newName) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemotes(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const remotes = await git.getRemotes(true);
|
||||
|
||||
return remotes.map((remote) => ({
|
||||
name: remote.name,
|
||||
fetchUrl: remote.refs.fetch,
|
||||
pushUrl: remote.refs.push
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get remotes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebase(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const { onto } = options;
|
||||
if (!onto) {
|
||||
throw new Error('onto parameter is required for rebase');
|
||||
}
|
||||
|
||||
await git.rebase([onto]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
conflict: false
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('could not apply') ||
|
||||
errorMessage.includes('merge conflict');
|
||||
|
||||
if (isConflict) {
|
||||
// Get list of conflicted files
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Failed to rebase:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortRebase(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
await git.rebase(['--abort']);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to abort rebase:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function merge(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const { branch } = options;
|
||||
if (!branch) {
|
||||
throw new Error('branch parameter is required for merge');
|
||||
}
|
||||
|
||||
await git.merge([branch]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
conflict: false
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('merge conflict') ||
|
||||
errorMessage.includes('automatic merge failed');
|
||||
|
||||
if (isConflict) {
|
||||
// Get list of conflicted files
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Failed to merge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortMerge(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
await git.merge(['--abort']);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to abort merge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueRebase(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
const git = await createGit(directoryPath);
|
||||
|
||||
try {
|
||||
// Set GIT_EDITOR to prevent editor prompts
|
||||
await git.env('GIT_EDITOR', 'true').rebase(['--continue']);
|
||||
return { success: true, conflict: false };
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('needs merge') ||
|
||||
errorMessage.includes('unmerged') ||
|
||||
errorMessage.includes('fix conflicts');
|
||||
|
||||
if (isConflict) {
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
// Check for "nothing to commit" which means rebase step is complete
|
||||
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) {
|
||||
// Skip this commit and continue
|
||||
try {
|
||||
await git.env('GIT_EDITOR', 'true').rebase(['--skip']);
|
||||
return { success: true, conflict: false };
|
||||
} catch {
|
||||
// If skip also fails, the rebase may be complete
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Failed to continue rebase:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueMerge(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
const git = await createGit(directoryPath);
|
||||
|
||||
try {
|
||||
// Check if there are still unmerged files
|
||||
const status = await git.status();
|
||||
if (status.conflicted && status.conflicted.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted
|
||||
};
|
||||
}
|
||||
|
||||
// For merge, we commit after resolving conflicts
|
||||
// Use --no-edit to use the default merge commit message
|
||||
await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null });
|
||||
return { success: true, conflict: false };
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('needs merge') ||
|
||||
errorMessage.includes('unmerged') ||
|
||||
errorMessage.includes('fix conflicts');
|
||||
|
||||
if (isConflict) {
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
// "nothing to commit" can happen if all conflicts resolved to one side
|
||||
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) {
|
||||
// The merge is effectively complete (all changes already committed or no changes needed)
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
console.error('Failed to continue merge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConflictDetails(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
const git = await createGit(directoryPath);
|
||||
|
||||
try {
|
||||
// Get git status --porcelain
|
||||
const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => '');
|
||||
|
||||
// Get unmerged files
|
||||
const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
|
||||
const unmergedFiles = unmergedFilesRaw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Get current diff
|
||||
const diff = await git.raw(['diff']).catch(() => '');
|
||||
|
||||
// Detect operation type and get head info
|
||||
let operation = 'merge';
|
||||
let headInfo = '';
|
||||
|
||||
// Check for MERGE_HEAD (merge in progress)
|
||||
const mergeHeadExists = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (mergeHeadExists) {
|
||||
operation = 'merge';
|
||||
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
||||
const mergeMsg = await fsp
|
||||
.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8')
|
||||
.catch(() => '');
|
||||
headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
|
||||
} else {
|
||||
// Check for REBASE_HEAD (rebase in progress)
|
||||
const rebaseHeadExists = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (rebaseHeadExists) {
|
||||
operation = 'rebase';
|
||||
const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => '');
|
||||
headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusPorcelain: statusPorcelain.trim(),
|
||||
unmergedFiles,
|
||||
diff: diff.trim(),
|
||||
headInfo: headInfo.trim(),
|
||||
operation,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get conflict details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
export async function stash(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
|
||||
await git.raw(args);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to stash:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stashPop(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
await git.raw(['stash', 'pop']);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to pop stash:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,14 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
createGitIdentity: gitApiHttp.createGitIdentity,
|
||||
updateGitIdentity: gitApiHttp.updateGitIdentity,
|
||||
deleteGitIdentity: gitApiHttp.deleteGitIdentity,
|
||||
getRemotes: gitApiHttp.getRemotes,
|
||||
rebase: gitApiHttp.rebase,
|
||||
abortRebase: gitApiHttp.abortRebase,
|
||||
continueRebase: gitApiHttp.continueRebase,
|
||||
merge: gitApiHttp.merge,
|
||||
abortMerge: gitApiHttp.abortMerge,
|
||||
continueMerge: gitApiHttp.continueMerge,
|
||||
stash: gitApiHttp.stash,
|
||||
stashPop: gitApiHttp.stashPop,
|
||||
getConflictDetails: gitApiHttp.getConflictDetails,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user