mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-08 22:55:41 +08:00
* fix: don't overlap thinking banner over conversation * feat: support message queue and add attention indicator * fix(shell): use c to copy for claude * fix: strip timestamp tags from cursor * fix: select project when loading session from direct URL * feat: support attached images for all providers * feat(opencode): support permission options * fix: resolve source control and chat UX bugs - make staging real in the git panel: new /stage and /unstage endpoints, /status now reports the actual index via porcelain -z (handles renames, conflicts, and paths with spaces), and Stage/Unstage All run real git commands instead of toggling client-side state - add branch search to the header dropdown and Branches tab - persist the chosen permission mode per provider so a brand-new chat keeps it once the session id arrives, instead of reverting to default - replace cmdk's fuzzy model search with strict token matching so "chatgpt" no longer surfaces unrelated models - unit tests for the git status parser * feat(git): commit graph in history view and cross-spawn everywhere - render a VSCode-style commit graph in the source control history: lane-assignment algorithm, SVG strip with colored rails, merge and branch curves, commit dots, and branch/tag badges per commit - /commits returns parents and ref decorations across all branches (--branches --remotes --tags --topo-order) using unit-separator fields so pipes in commit subjects can't break parsing - collect stats via a single --shortstat pass instead of one `git show --stat` call per commit; raise the history limit to 50 - replace child_process spawn with cross-spawn in all runtimes, routes, and services: it resolves .cmd shims and PATHEXT on Windows (fixing taskmaster's npx invocations) and delegates to native spawn elsewhere, removing the per-file win32 ternaries - unit tests for the log parser and lane assignment * fix: remove gemini support since google discontinued it * fix: address code review findings - validate chat image attachments server-side: only files inside the ~/.cloudcli/assets upload store may reach provider file reads - harden asset serving with nosniff and attachment disposition for SVGs to prevent stored XSS - show the timestamp on image-only user messages - serialize git stage/unstage calls and defer the status re-sync so rapid toggles can't interleave or flicker - use a literal hex fallback for commit ref badges (alpha suffix on a var() string produced invalid CSS) - stop binding a URL session to a guening project instead - add the missing attentionRequiredIndicator key to all sidebar locales - clean up dangling conjunctions left the de/ru/tr/ja/zh-CN/zh-TW READMEs * fix: address code scanning findings - enforce a second image trust boundary in the provider builders: buildClaudeUserContent/buildCodexInputItems only accept files inside the upload store or the run's working directory (CodeQL path injection) - drop an always-true selectedProject check in handleSubmit - remove the unused resume option from spawnCursor * fix: use CodeQL-recognized containment check for image reads Replace the path.relative guard in isPathInsideDirectory with the resolve + startsWith(root + sep) idiom so the path-injection barrier is visible to code scanning; behavior is unchanged. * fix(security): canonicalize Claude image attachment reads Resolve image attachment paths with realpath before reading so symlinks inside allowed roots cannot escape to arbitrary files. Preserve support for symlinked project/upload roots and add focused coverage for both cases. * fix: show agent subtask * fix(sessions): title app-created sessions from the first user message App-created sessions (started by sending a message from cloudcli) were titled with placeholder names — "Untitled Codex Session" from the disk indexer and, briefly, "New session" from the empty canonical upsert — before a later sync finally settled on the right text, causing the sidebar title to flicker. - Codex/OpenCode synchronizers now title app-created sessions (distinct app id mapped to a provider id) from the first user message, while sessions found purely by indexing keep their existing setup. Claude keeps its AI-generated titles; Cursor already used the first message. - Decode OpenCode's JSON-string-literal prompts so titles no longer surface wrapped in quotes; hoist unwrapJsonStringLiteral into shared/utils since it's now used by both the reader and synchronizer. - Guard the sidebar upsert merge so an empty summary can never blank out a title that is already set. - Add codex/opencode synchronizer tests for the app-created vs indexed naming paths. * fix(chat): make message queuing reliable across sessions and turn boundaries Queued messages had four related defects: - A queued message flashed and then vanished at flush time: concurrent transcript refreshes (the `complete` handler racing the watcher-triggered update) could resolve out of order, letting a stale response overwrite newer server messages after the optimistic row was pruned. Session slots now carry a monotonic fetch ticket and discard stale fetch/refresh/ fetchMore responses. - Switching sessions flushed the previous session's queued draft into the newly viewed one (sending it with the wrong provider's settings, e.g. a Claude model into a Codex session). The composer flush is now scoped to its session, and a draft restored into an idle session sends after a short grace period so a live-run ack can cancel it. - The thinking banner never appeared for a queued turn: the chat handler's session-keyed completeRun safety net could fire after a queued message had already started the session's next run, emitting a spurious `complete` that killed it. The safety net is now scoped to its own run via completeRunIfCurrent (with a regression test). - Queued messages only sent while their session was being viewed. Drafts now persist their send options (model, effort, permissions) at queue time, and a new app-level useQueuedMessageAutoSend hook dispatches a non-viewed session's queued message as soon as its run completes, using the storage key as the claim ticket to prevent double sends.
1635 lines
55 KiB
JavaScript
Executable File
1635 lines
55 KiB
JavaScript
Executable File
import express from 'express';
|
|
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
|
import spawn from 'cross-spawn';
|
|
import path from 'path';
|
|
import { promises as fs } from 'fs';
|
|
import { projectsDb } from '../modules/database/index.js';
|
|
import { queryClaudeSDK } from '../claude-sdk.js';
|
|
import { spawnCursor } from '../cursor-cli.js';
|
|
|
|
const router = express.Router();
|
|
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
|
|
|
|
function spawnAsync(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
...options,
|
|
shell: false,
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ stdout, stderr });
|
|
return;
|
|
}
|
|
|
|
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
|
|
error.code = code;
|
|
error.stdout = stdout;
|
|
error.stderr = stderr;
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Input validation helpers (defense-in-depth)
|
|
function validateCommitRef(commit) {
|
|
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
|
|
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
|
|
throw new Error('Invalid commit reference');
|
|
}
|
|
return commit;
|
|
}
|
|
|
|
function validateBranchName(branch) {
|
|
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
|
|
throw new Error('Invalid branch name');
|
|
}
|
|
return branch;
|
|
}
|
|
|
|
function validateFilePath(file, projectPath) {
|
|
if (!file || file.includes('\0')) {
|
|
throw new Error('Invalid file path');
|
|
}
|
|
// Prevent path traversal: resolve the file relative to the project root
|
|
// and ensure the result stays within the project directory
|
|
if (projectPath) {
|
|
const resolved = path.resolve(projectPath, file);
|
|
const normalizedRoot = path.resolve(projectPath) + path.sep;
|
|
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
|
|
throw new Error('Invalid file path: path traversal detected');
|
|
}
|
|
}
|
|
return file;
|
|
}
|
|
|
|
function validateRemoteName(remote) {
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
|
|
throw new Error('Invalid remote name');
|
|
}
|
|
return remote;
|
|
}
|
|
|
|
function validateProjectPath(projectPath) {
|
|
if (!projectPath || projectPath.includes('\0')) {
|
|
throw new Error('Invalid project path');
|
|
}
|
|
const resolved = path.resolve(projectPath);
|
|
// Must be an absolute path after resolution
|
|
if (!path.isAbsolute(resolved)) {
|
|
throw new Error('Invalid project path: must be absolute');
|
|
}
|
|
// Block obviously dangerous paths
|
|
if (resolved === '/' || resolved === path.sep) {
|
|
throw new Error('Invalid project path: root directory not allowed');
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute project directory for a given DB `projectId`.
|
|
*
|
|
* After the projectName → projectId migration, every git endpoint receives
|
|
* the DB primary key (`project` query/body param). The legacy filesystem
|
|
* resolver that walked Claude's JSONL history is no longer used here; the
|
|
* path comes straight from the `projects` table and is then sanity-checked
|
|
* by `validateProjectPath` before any `git` command runs against it.
|
|
*/
|
|
async function getActualProjectPath(projectId) {
|
|
const projectPath = await projectsDb.getProjectPathById(projectId);
|
|
if (!projectPath) {
|
|
throw new Error(`Unable to resolve project path for "${projectId}"`);
|
|
}
|
|
return validateProjectPath(projectPath);
|
|
}
|
|
|
|
// Helper function to strip git diff headers
|
|
function stripDiffHeaders(diff) {
|
|
if (!diff) return '';
|
|
|
|
const lines = diff.split('\n');
|
|
const filteredLines = [];
|
|
let startIncluding = false;
|
|
|
|
for (const line of lines) {
|
|
// Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
|
|
if (line.startsWith('diff --git') ||
|
|
line.startsWith('index ') ||
|
|
line.startsWith('new file mode') ||
|
|
line.startsWith('deleted file mode') ||
|
|
line.startsWith('---') ||
|
|
line.startsWith('+++')) {
|
|
continue;
|
|
}
|
|
|
|
// Start including lines from @@ hunk headers onwards
|
|
if (line.startsWith('@@') || startIncluding) {
|
|
startIncluding = true;
|
|
filteredLines.push(line);
|
|
}
|
|
}
|
|
|
|
return filteredLines.join('\n');
|
|
}
|
|
|
|
// Helper function to validate git repository
|
|
async function validateGitRepository(projectPath) {
|
|
try {
|
|
// Check if directory exists
|
|
await fs.access(projectPath);
|
|
} catch {
|
|
throw new Error(`Project path not found: ${projectPath}`);
|
|
}
|
|
|
|
try {
|
|
// Allow any directory that is inside a work tree (repo root or nested folder).
|
|
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
|
|
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
|
if (!isInsideWorkTree) {
|
|
throw new Error('Not inside a git work tree');
|
|
}
|
|
|
|
// Ensure git can resolve the repository root for this directory.
|
|
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
|
} catch {
|
|
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
|
}
|
|
}
|
|
|
|
function getGitErrorDetails(error) {
|
|
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
|
|
}
|
|
|
|
function isMissingHeadRevisionError(error) {
|
|
const errorDetails = getGitErrorDetails(error).toLowerCase();
|
|
return errorDetails.includes('unknown revision')
|
|
|| errorDetails.includes('ambiguous argument')
|
|
|| errorDetails.includes('needed a single revision')
|
|
|| errorDetails.includes('bad revision');
|
|
}
|
|
|
|
async function getCurrentBranchName(projectPath) {
|
|
try {
|
|
// symbolic-ref works even when the repository has no commits.
|
|
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
|
|
const branchName = stdout.trim();
|
|
if (branchName) {
|
|
return branchName;
|
|
}
|
|
} catch (error) {
|
|
// Fall back to rev-parse for detached HEAD and older git edge cases.
|
|
}
|
|
|
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function repositoryHasCommits(projectPath) {
|
|
try {
|
|
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
|
return true;
|
|
} catch (error) {
|
|
if (isMissingHeadRevisionError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getRepositoryRootPath(projectPath) {
|
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
|
return stdout.trim();
|
|
}
|
|
|
|
function normalizeRepositoryRelativeFilePath(filePath) {
|
|
return String(filePath)
|
|
.replace(/\\/g, '/')
|
|
.replace(/^\.\/+/, '')
|
|
.replace(/^\/+/, '')
|
|
.trim();
|
|
}
|
|
|
|
function parseStatusFilePaths(statusOutput) {
|
|
return statusOutput
|
|
.split('\n')
|
|
.map((line) => line.trimEnd())
|
|
.filter((line) => line.trim())
|
|
.map((line) => {
|
|
const statusPath = line.substring(3);
|
|
const renamedFilePath = statusPath.split(' -> ')[1];
|
|
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
|
|
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
|
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
|
|
const candidates = [normalizedFilePath];
|
|
|
|
if (
|
|
projectRelativePath
|
|
&& projectRelativePath !== '.'
|
|
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
|
|
) {
|
|
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
|
|
}
|
|
|
|
return Array.from(new Set(candidates.filter(Boolean)));
|
|
}
|
|
|
|
async function resolveRepositoryFilePath(projectPath, filePath) {
|
|
validateFilePath(filePath);
|
|
|
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
|
|
|
|
for (const candidateFilePath of candidateFilePaths) {
|
|
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
|
|
if (stdout.trim()) {
|
|
return {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath: candidateFilePath,
|
|
};
|
|
}
|
|
}
|
|
|
|
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
|
|
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
|
if (!normalizedFilePath.includes('/')) {
|
|
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
|
|
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
|
|
const suffixMatches = changedFilePaths.filter(
|
|
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
|
|
);
|
|
|
|
if (suffixMatches.length === 1) {
|
|
return {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath: suffixMatches[0],
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath: candidateFilePaths[0],
|
|
};
|
|
}
|
|
|
|
// Get git status for a project
|
|
/**
|
|
* Parses `git status --porcelain=v1 -z` output into the response shape the
|
|
* git panel consumes. NUL-separated entries carry no path quoting, so names
|
|
* with spaces/unicode survive intact (the plain porcelain output quotes and
|
|
* escapes them, which broke the old line-based parser).
|
|
*
|
|
* `staged` lists paths with index-side changes. The UI renders its "Staged"
|
|
* section from this list so it always mirrors the real git index (including
|
|
* files staged outside the app, e.g. via VSCode or the terminal).
|
|
*
|
|
* Exported for tests.
|
|
*/
|
|
export function parseGitStatusOutput(statusOutput) {
|
|
const modified = [];
|
|
const added = [];
|
|
const deleted = [];
|
|
const untracked = [];
|
|
const staged = [];
|
|
|
|
const statusEntries = statusOutput.split('\0');
|
|
for (let entryIndex = 0; entryIndex < statusEntries.length; entryIndex++) {
|
|
const entry = statusEntries[entryIndex];
|
|
if (!entry || entry.length < 4) continue;
|
|
|
|
// Porcelain v1: X = index (staged) status, Y = worktree (unstaged) status.
|
|
const indexStatus = entry[0];
|
|
const worktreeStatus = entry[1];
|
|
const file = entry.slice(3);
|
|
|
|
// Renames/copies carry the original path as the following NUL entry;
|
|
// the UI tracks the post-rename path only.
|
|
if (indexStatus === 'R' || indexStatus === 'C') {
|
|
entryIndex += 1;
|
|
}
|
|
|
|
if (indexStatus === '?') {
|
|
untracked.push(file);
|
|
continue;
|
|
}
|
|
if (indexStatus === '!') {
|
|
continue; // ignored files are never reported
|
|
}
|
|
|
|
const isConflict =
|
|
indexStatus === 'U' || worktreeStatus === 'U' ||
|
|
(indexStatus === 'A' && worktreeStatus === 'A') ||
|
|
(indexStatus === 'D' && worktreeStatus === 'D');
|
|
if (isConflict) {
|
|
// Merge conflicts must be resolved in the worktree first; surface them
|
|
// as modified and never as staged.
|
|
modified.push(file);
|
|
continue;
|
|
}
|
|
|
|
if (indexStatus !== ' ') {
|
|
staged.push(file);
|
|
}
|
|
|
|
if (indexStatus === 'D' || worktreeStatus === 'D') {
|
|
deleted.push(file);
|
|
} else if (indexStatus === 'A' || worktreeStatus === 'A') {
|
|
added.push(file);
|
|
} else {
|
|
modified.push(file);
|
|
}
|
|
}
|
|
|
|
return { modified, added, deleted, untracked, staged };
|
|
}
|
|
|
|
router.get('/status', async (req, res) => {
|
|
const { project } = req.query;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
|
|
const branch = await getCurrentBranchName(projectPath);
|
|
const hasCommits = await repositoryHasCommits(projectPath);
|
|
|
|
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain=v1', '-z'], { cwd: projectPath });
|
|
const { modified, added, deleted, untracked, staged } = parseGitStatusOutput(statusOutput);
|
|
|
|
res.json({
|
|
branch,
|
|
hasCommits,
|
|
modified,
|
|
added,
|
|
deleted,
|
|
untracked,
|
|
staged
|
|
});
|
|
} catch (error) {
|
|
console.error('Git status error:', error);
|
|
res.json({
|
|
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
|
? error.message
|
|
: 'Git operation failed',
|
|
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
|
? error.message
|
|
: `Failed to get git status: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get diff for a specific file
|
|
router.get('/diff', async (req, res) => {
|
|
const { project, file } = req.query;
|
|
|
|
if (!project || !file) {
|
|
return res.status(400).json({ error: 'Project id and file path are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
|
|
const {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath,
|
|
} = await resolveRepositoryFilePath(projectPath, file);
|
|
|
|
// Check if file is untracked or deleted
|
|
const { stdout: statusOutput } = await spawnAsync(
|
|
'git',
|
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
const isUntracked = statusOutput.startsWith('??');
|
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
|
|
|
let diff;
|
|
if (isUntracked) {
|
|
// For untracked files, show the entire file content as additions
|
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// For directories, show a simple message
|
|
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
|
|
} else {
|
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
const lines = fileContent.split('\n');
|
|
diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
|
|
lines.map(line => `+${line}`).join('\n');
|
|
}
|
|
} else if (isDeleted) {
|
|
// For deleted files, show the entire file content from HEAD as deletions
|
|
const { stdout: fileContent } = await spawnAsync(
|
|
'git',
|
|
['show', `HEAD:${repositoryRelativeFilePath}`],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
const lines = fileContent.split('\n');
|
|
diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
|
lines.map(line => `-${line}`).join('\n');
|
|
} else {
|
|
// Get diff for tracked files
|
|
// First check for unstaged changes (working tree vs index)
|
|
const { stdout: unstagedDiff } = await spawnAsync(
|
|
'git',
|
|
['diff', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
|
|
if (unstagedDiff) {
|
|
// Show unstaged changes if they exist
|
|
diff = stripDiffHeaders(unstagedDiff);
|
|
} else {
|
|
// If no unstaged changes, check for staged changes (index vs HEAD)
|
|
const { stdout: stagedDiff } = await spawnAsync(
|
|
'git',
|
|
['diff', '--cached', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
diff = stripDiffHeaders(stagedDiff) || '';
|
|
}
|
|
}
|
|
|
|
res.json({ diff });
|
|
} catch (error) {
|
|
console.error('Git diff error:', error);
|
|
res.json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get file content with diff information for CodeEditor
|
|
router.get('/file-with-diff', async (req, res) => {
|
|
const { project, file } = req.query;
|
|
|
|
if (!project || !file) {
|
|
return res.status(400).json({ error: 'Project id and file path are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
|
|
const {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath,
|
|
} = await resolveRepositoryFilePath(projectPath, file);
|
|
|
|
// Check file status
|
|
const { stdout: statusOutput } = await spawnAsync(
|
|
'git',
|
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
const isUntracked = statusOutput.startsWith('??');
|
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
|
|
|
let currentContent = '';
|
|
let oldContent = '';
|
|
|
|
if (isDeleted) {
|
|
// For deleted files, get content from HEAD
|
|
const { stdout: headContent } = await spawnAsync(
|
|
'git',
|
|
['show', `HEAD:${repositoryRelativeFilePath}`],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
oldContent = headContent;
|
|
currentContent = headContent; // Show the deleted content in editor
|
|
} else {
|
|
// Get current file content
|
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Cannot show content for directories
|
|
return res.status(400).json({ error: 'Cannot show diff for directories' });
|
|
}
|
|
|
|
currentContent = await fs.readFile(filePath, 'utf-8');
|
|
|
|
if (!isUntracked) {
|
|
// Get the old content from HEAD for tracked files
|
|
try {
|
|
const { stdout: headContent } = await spawnAsync(
|
|
'git',
|
|
['show', `HEAD:${repositoryRelativeFilePath}`],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
oldContent = headContent;
|
|
} catch (error) {
|
|
// File might be newly added to git (staged but not committed)
|
|
oldContent = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
currentContent,
|
|
oldContent,
|
|
isDeleted,
|
|
isUntracked
|
|
});
|
|
} catch (error) {
|
|
console.error('Git file-with-diff error:', error);
|
|
res.json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Create initial commit
|
|
router.post('/initial-commit', async (req, res) => {
|
|
const { project } = req.body;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Check if there are already commits
|
|
try {
|
|
await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
|
|
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
|
} catch (error) {
|
|
// No HEAD - this is good, we can create initial commit
|
|
}
|
|
|
|
// Add all files
|
|
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
|
|
|
|
// Create initial commit
|
|
const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
|
|
|
|
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
|
} catch (error) {
|
|
console.error('Git initial commit error:', error);
|
|
|
|
// Handle the case where there's nothing to commit
|
|
if (error.message.includes('nothing to commit')) {
|
|
return res.status(400).json({
|
|
error: 'Nothing to commit',
|
|
details: 'No files found in the repository. Add some files first.'
|
|
});
|
|
}
|
|
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Commit changes
|
|
router.post('/commit', async (req, res) => {
|
|
const { project, message, files } = req.body;
|
|
|
|
if (!project || !message || !files || files.length === 0) {
|
|
return res.status(400).json({ error: 'Project name, commit message, and files are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
|
|
// Stage selected files
|
|
for (const file of files) {
|
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
}
|
|
|
|
// Commit with message
|
|
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
|
|
|
|
res.json({ success: true, output: stdout });
|
|
} catch (error) {
|
|
console.error('Git commit error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Stage files (git add). Mirrors what the UI shows as the "Staged" section,
|
|
// so the app's staging state and the real git index never drift apart.
|
|
router.post('/stage', async (req, res) => {
|
|
const { project, files } = req.body;
|
|
|
|
if (!project || !Array.isArray(files) || files.length === 0) {
|
|
return res.status(400).json({ error: 'Project id and files are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
|
|
for (const file of files) {
|
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Git stage error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Unstage files (remove from the index, keep the worktree changes)
|
|
router.post('/unstage', async (req, res) => {
|
|
const { project, files } = req.body;
|
|
|
|
if (!project || !Array.isArray(files) || files.length === 0) {
|
|
return res.status(400).json({ error: 'Project id and files are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
const hasCommits = await repositoryHasCommits(projectPath);
|
|
|
|
for (const file of files) {
|
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
if (hasCommits) {
|
|
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
} else {
|
|
// No HEAD to reset against before the first commit; dropping the
|
|
// index entry is the only way to unstage while keeping the file.
|
|
await spawnAsync('git', ['rm', '--cached', '-r', '--force', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Git unstage error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Revert latest local commit (keeps changes staged)
|
|
router.post('/revert-local-commit', async (req, res) => {
|
|
const { project } = req.body;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
try {
|
|
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
|
} catch (error) {
|
|
return res.status(400).json({
|
|
error: 'No local commit to revert',
|
|
details: 'This repository has no commit yet.',
|
|
});
|
|
}
|
|
|
|
try {
|
|
// Soft reset rewinds one commit while preserving all file changes in the index.
|
|
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
|
|
} catch (error) {
|
|
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
|
|
const isInitialCommit = errorDetails.includes('HEAD~1') &&
|
|
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
|
|
|
|
if (!isInitialCommit) {
|
|
throw error;
|
|
}
|
|
|
|
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
|
|
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
output: 'Latest local commit reverted successfully. Changes were kept staged.',
|
|
});
|
|
} catch (error) {
|
|
console.error('Git revert local commit error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get list of branches
|
|
router.get('/branches', async (req, res) => {
|
|
const { project } = req.query;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Get all branches
|
|
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
|
|
|
|
const rawLines = stdout
|
|
.split('\n')
|
|
.map(b => b.trim())
|
|
.filter(b => b && !b.includes('->'));
|
|
|
|
// Local branches (may start with '* ' for current)
|
|
const localBranches = rawLines
|
|
.filter(b => !b.startsWith('remotes/'))
|
|
.map(b => (b.startsWith('* ') ? b.substring(2) : b));
|
|
|
|
// Remote branches — strip 'remotes/<remote>/' prefix
|
|
const remoteBranches = rawLines
|
|
.filter(b => b.startsWith('remotes/'))
|
|
.map(b => b.replace(/^remotes\/[^/]+\//, ''))
|
|
.filter(name => !localBranches.includes(name)); // skip if already a local branch
|
|
|
|
// Backward-compat flat list (local + unique remotes, deduplicated)
|
|
const branches = [...localBranches, ...remoteBranches]
|
|
.filter((b, i, arr) => arr.indexOf(b) === i);
|
|
|
|
res.json({ branches, localBranches, remoteBranches });
|
|
} catch (error) {
|
|
console.error('Git branches error:', error);
|
|
res.json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Checkout branch
|
|
router.post('/checkout', async (req, res) => {
|
|
const { project, branch } = req.body;
|
|
|
|
if (!project || !branch) {
|
|
return res.status(400).json({ error: 'Project id and branch are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Checkout the branch
|
|
validateBranchName(branch);
|
|
const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
|
|
|
|
res.json({ success: true, output: stdout });
|
|
} catch (error) {
|
|
console.error('Git checkout error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Create new branch
|
|
router.post('/create-branch', async (req, res) => {
|
|
const { project, branch } = req.body;
|
|
|
|
if (!project || !branch) {
|
|
return res.status(400).json({ error: 'Project id and branch name are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Create and checkout new branch
|
|
validateBranchName(branch);
|
|
const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
|
|
|
|
res.json({ success: true, output: stdout });
|
|
} catch (error) {
|
|
console.error('Git create branch error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete a local branch
|
|
router.post('/delete-branch', async (req, res) => {
|
|
const { project, branch } = req.body;
|
|
|
|
if (!project || !branch) {
|
|
return res.status(400).json({ error: 'Project id and branch name are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Safety: cannot delete the currently checked-out branch
|
|
const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
|
|
if (currentBranch.trim() === branch) {
|
|
return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
|
|
}
|
|
|
|
const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
|
|
res.json({ success: true, output: stdout });
|
|
} catch (error) {
|
|
console.error('Git delete branch error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Fields are joined with the ASCII unit separator so pipes (or anything else
|
|
// typed into a commit subject) cannot break parsing.
|
|
const GIT_LOG_FIELD_SEPARATOR = '\u001f';
|
|
const GIT_LOG_PRETTY_FORMAT = '%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s';
|
|
|
|
/**
|
|
* Parses `git log --shortstat` output produced with GIT_LOG_PRETTY_FORMAT.
|
|
*
|
|
* Each commit is one format line (hash, parent hashes, ref decorations,
|
|
* author, email, date, subject) optionally followed by its `--shortstat`
|
|
* summary line ("N files changed, ..."). Parents and refs feed the commit
|
|
* graph rendered by the History view; merge commits carry no shortstat line,
|
|
* so their `stats` stays empty.
|
|
*
|
|
* Exported for tests.
|
|
*/
|
|
export function parseGitLogWithStats(stdout) {
|
|
const commits = [];
|
|
|
|
for (const rawLine of stdout.split('\n')) {
|
|
const line = rawLine.trimEnd();
|
|
if (!line.trim()) continue;
|
|
|
|
if (line.includes(GIT_LOG_FIELD_SEPARATOR)) {
|
|
const [hash, parents, refs, author, email, date, ...messageParts] = line.split(GIT_LOG_FIELD_SEPARATOR);
|
|
commits.push({
|
|
hash,
|
|
parents: parents ? parents.split(' ').filter(Boolean) : [],
|
|
// `%D` decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0".
|
|
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
|
author,
|
|
email,
|
|
date,
|
|
message: messageParts.join(GIT_LOG_FIELD_SEPARATOR),
|
|
stats: ''
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (commits.length > 0 && /files? changed/.test(line)) {
|
|
commits[commits.length - 1].stats = line.trim();
|
|
}
|
|
}
|
|
|
|
return commits;
|
|
}
|
|
|
|
// Get recent commits (across all branches, in graph order)
|
|
router.get('/commits', async (req, res) => {
|
|
const { project, limit = 10 } = req.query;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
const parsedLimit = Number.parseInt(String(limit), 10);
|
|
const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
|
? Math.min(parsedLimit, 100)
|
|
: 10;
|
|
|
|
// Branches/remotes/tags (not --all, which would drag in refs/stash) with
|
|
// `--topo-order` guarantee children appear before their parents across
|
|
// every branch, which the frontend lane-assignment relies on.
|
|
// `--shortstat` replaces the previous per-commit `git show --stat` calls.
|
|
const { stdout } = await spawnAsync(
|
|
'git',
|
|
[
|
|
'log',
|
|
'--branches',
|
|
'--remotes',
|
|
'--tags',
|
|
'--topo-order',
|
|
'--shortstat',
|
|
`--pretty=format:${GIT_LOG_PRETTY_FORMAT}`,
|
|
'--date=iso-strict',
|
|
'-n', String(safeLimit)
|
|
],
|
|
{ cwd: projectPath },
|
|
);
|
|
|
|
res.json({ commits: parseGitLogWithStats(stdout) });
|
|
} catch (error) {
|
|
console.error('Git commits error:', error);
|
|
res.json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get diff for a specific commit
|
|
router.get('/commit-diff', async (req, res) => {
|
|
const { project, commit } = req.query;
|
|
|
|
if (!project || !commit) {
|
|
return res.status(400).json({ error: 'Project id and commit hash are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
|
|
// Validate commit reference (defense-in-depth)
|
|
validateCommitRef(commit);
|
|
|
|
// Get diff for the commit
|
|
const { stdout } = await spawnAsync(
|
|
'git', ['show', commit],
|
|
{ cwd: projectPath }
|
|
);
|
|
|
|
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
|
|
const diff = isTruncated
|
|
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
|
|
: stdout;
|
|
|
|
res.json({ diff, isTruncated });
|
|
} catch (error) {
|
|
console.error('Git commit diff error:', error);
|
|
res.json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Generate commit message based on staged changes using AI
|
|
router.post('/generate-commit-message', async (req, res) => {
|
|
const { project, files, provider = 'claude' } = req.body;
|
|
|
|
if (!project || !files || files.length === 0) {
|
|
return res.status(400).json({ error: 'Project id and files are required' });
|
|
}
|
|
|
|
// Validate provider
|
|
if (!['claude', 'cursor'].includes(provider)) {
|
|
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
|
|
// Get diff for selected files
|
|
let diffContext = '';
|
|
for (const file of files) {
|
|
try {
|
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
const { stdout } = await spawnAsync(
|
|
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath }
|
|
);
|
|
if (stdout) {
|
|
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error getting diff for ${file}:`, error);
|
|
}
|
|
}
|
|
|
|
// If no diff found, might be untracked files
|
|
if (!diffContext.trim()) {
|
|
// Try to get content of untracked files
|
|
for (const file of files) {
|
|
try {
|
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
|
|
} else {
|
|
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error reading file ${file}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate commit message using AI
|
|
const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
|
|
|
|
res.json({ message });
|
|
} catch (error) {
|
|
console.error('Generate commit message error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Generates a commit message using AI (Claude SDK or Cursor CLI)
|
|
* @param {Array<string>} files - List of changed files
|
|
* @param {string} diffContext - Git diff content
|
|
* @param {string} provider - 'claude' or 'cursor'
|
|
* @param {string} projectPath - Project directory path
|
|
* @returns {Promise<string>} Generated commit message
|
|
*/
|
|
async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
|
|
// Create the prompt
|
|
const prompt = `Generate a conventional commit message for these changes.
|
|
|
|
REQUIREMENTS:
|
|
- Format: type(scope): subject
|
|
- Include body explaining what changed and why
|
|
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
|
- Subject under 50 chars, body wrapped at 72 chars
|
|
- Focus on user-facing changes, not implementation details
|
|
- Consider what's being added AND removed
|
|
- Return ONLY the commit message (no markdown, explanations, or code blocks)
|
|
|
|
FILES CHANGED:
|
|
${files.map(f => `- ${f}`).join('\n')}
|
|
|
|
DIFFS:
|
|
${diffContext.substring(0, 4000)}
|
|
|
|
Generate the commit message:`;
|
|
|
|
try {
|
|
// Create a simple writer that collects the response
|
|
let responseText = '';
|
|
const writer = {
|
|
send: (data) => {
|
|
try {
|
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
console.log('🔍 Writer received message type:', parsed.type);
|
|
|
|
// Handle different message formats from Claude SDK and Cursor CLI
|
|
// Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
|
|
if (parsed.type === 'claude-response' && parsed.data) {
|
|
const message = parsed.data.message || parsed.data;
|
|
console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
|
|
if (message.content && Array.isArray(message.content)) {
|
|
// Extract text from content array
|
|
for (const item of message.content) {
|
|
if (item.type === 'text' && item.text) {
|
|
console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
|
|
responseText += item.text;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Cursor CLI sends: {type: 'cursor-output', output: '...'}
|
|
else if (parsed.type === 'cursor-output' && parsed.output) {
|
|
console.log('✅ Cursor output:', parsed.output.substring(0, 100));
|
|
responseText += parsed.output;
|
|
}
|
|
// Also handle direct text messages
|
|
else if (parsed.type === 'text' && parsed.text) {
|
|
console.log('✅ Direct text:', parsed.text.substring(0, 100));
|
|
responseText += parsed.text;
|
|
}
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
console.error('Error parsing writer data:', e);
|
|
}
|
|
},
|
|
setSessionId: () => {}, // No-op for this use case
|
|
};
|
|
|
|
console.log('🚀 Calling AI agent with provider:', provider);
|
|
console.log('📝 Prompt length:', prompt.length);
|
|
|
|
// Call the appropriate agent
|
|
if (provider === 'claude') {
|
|
await queryClaudeSDK(prompt, {
|
|
cwd: projectPath,
|
|
permissionMode: 'bypassPermissions',
|
|
model: 'sonnet'
|
|
}, writer);
|
|
} else if (provider === 'cursor') {
|
|
await spawnCursor(prompt, {
|
|
cwd: projectPath,
|
|
skipPermissions: true
|
|
}, writer);
|
|
}
|
|
|
|
console.log('📊 Total response text collected:', responseText.length, 'characters');
|
|
console.log('📄 Response preview:', responseText.substring(0, 200));
|
|
|
|
// Clean up the response
|
|
const cleanedMessage = cleanCommitMessage(responseText);
|
|
console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
|
|
|
|
return cleanedMessage || 'chore: update files';
|
|
} catch (error) {
|
|
console.error('Error generating commit message with AI:', error);
|
|
// Fallback to simple message
|
|
return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
|
|
* @param {string} text - Raw AI response
|
|
* @returns {string} Clean commit message
|
|
*/
|
|
function cleanCommitMessage(text) {
|
|
if (!text || !text.trim()) {
|
|
return '';
|
|
}
|
|
|
|
let cleaned = text.trim();
|
|
|
|
// Remove markdown code blocks
|
|
cleaned = cleaned.replace(/```[a-z]*\n/g, '');
|
|
cleaned = cleaned.replace(/```/g, '');
|
|
|
|
// Remove markdown headers
|
|
cleaned = cleaned.replace(/^#+\s*/gm, '');
|
|
|
|
// Remove leading/trailing quotes
|
|
cleaned = cleaned.replace(/^["']|["']$/g, '');
|
|
|
|
// If there are multiple lines, take everything (subject + body)
|
|
// Just clean up extra blank lines
|
|
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
|
|
|
// Remove any explanatory text before the actual commit message
|
|
// Look for conventional commit pattern and start from there
|
|
const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
|
|
if (conventionalCommitMatch) {
|
|
cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
|
|
}
|
|
|
|
return cleaned.trim();
|
|
}
|
|
|
|
// Get remote status (ahead/behind commits with smart remote detection)
|
|
router.get('/remote-status', async (req, res) => {
|
|
const { project } = req.query;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
const branch = await getCurrentBranchName(projectPath);
|
|
const hasCommits = await repositoryHasCommits(projectPath);
|
|
|
|
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
|
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
|
|
const hasRemote = remotes.length > 0;
|
|
const fallbackRemoteName = hasRemote
|
|
? (remotes.includes('origin') ? 'origin' : remotes[0])
|
|
: null;
|
|
|
|
// Repositories initialized with `git init` can have a branch but no commits.
|
|
// Return a non-error state so the UI can show the initial-commit workflow.
|
|
if (!hasCommits) {
|
|
return res.json({
|
|
hasRemote,
|
|
hasUpstream: false,
|
|
branch,
|
|
remoteName: fallbackRemoteName,
|
|
ahead: 0,
|
|
behind: 0,
|
|
isUpToDate: false,
|
|
message: 'Repository has no commits yet'
|
|
});
|
|
}
|
|
|
|
// Check if there's a remote tracking branch (smart detection)
|
|
let trackingBranch;
|
|
let remoteName;
|
|
try {
|
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
|
trackingBranch = stdout.trim();
|
|
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
|
} catch (error) {
|
|
return res.json({
|
|
hasRemote,
|
|
hasUpstream: false,
|
|
branch,
|
|
remoteName: fallbackRemoteName,
|
|
message: 'No remote tracking branch configured'
|
|
});
|
|
}
|
|
|
|
// Get ahead/behind counts
|
|
const { stdout: countOutput } = await spawnAsync(
|
|
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
|
|
{ cwd: projectPath }
|
|
);
|
|
|
|
const [behind, ahead] = countOutput.trim().split('\t').map(Number);
|
|
|
|
res.json({
|
|
hasRemote: true,
|
|
hasUpstream: true,
|
|
branch,
|
|
remoteBranch: trackingBranch,
|
|
remoteName,
|
|
ahead: ahead || 0,
|
|
behind: behind || 0,
|
|
isUpToDate: ahead === 0 && behind === 0
|
|
});
|
|
} catch (error) {
|
|
console.error('Git remote status error:', error);
|
|
res.json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Fetch from remote (using smart remote detection)
|
|
router.post('/fetch', async (req, res) => {
|
|
const { project } = req.body;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Get current branch and its upstream remote
|
|
const branch = await getCurrentBranchName(projectPath);
|
|
|
|
let remoteName = 'origin'; // fallback
|
|
try {
|
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
|
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
|
} catch (error) {
|
|
// No upstream, try to fetch from origin anyway
|
|
console.log('No upstream configured, using origin as fallback');
|
|
}
|
|
|
|
validateRemoteName(remoteName);
|
|
const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
|
|
|
|
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
|
} catch (error) {
|
|
console.error('Git fetch error:', error);
|
|
res.status(500).json({
|
|
error: 'Fetch failed',
|
|
details: error.message.includes('Could not resolve hostname')
|
|
? 'Unable to connect to remote repository. Check your internet connection.'
|
|
: error.message.includes('fatal: \'origin\' does not appear to be a git repository')
|
|
? 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
|
: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Pull from remote (fetch + merge using smart remote detection)
|
|
router.post('/pull', async (req, res) => {
|
|
const { project } = req.body;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Get current branch and its upstream remote
|
|
const branch = await getCurrentBranchName(projectPath);
|
|
|
|
let remoteName = 'origin'; // fallback
|
|
let remoteBranch = branch; // fallback
|
|
try {
|
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
|
const tracking = stdout.trim();
|
|
remoteName = tracking.split('/')[0]; // Extract remote name
|
|
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
|
} catch (error) {
|
|
// No upstream, use fallback
|
|
console.log('No upstream configured, using origin/branch as fallback');
|
|
}
|
|
|
|
validateRemoteName(remoteName);
|
|
validateBranchName(remoteBranch);
|
|
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
|
|
|
|
res.json({
|
|
success: true,
|
|
output: stdout || 'Pull completed successfully',
|
|
remoteName,
|
|
remoteBranch
|
|
});
|
|
} catch (error) {
|
|
console.error('Git pull error:', error);
|
|
|
|
// Enhanced error handling for common pull scenarios
|
|
let errorMessage = 'Pull failed';
|
|
let details = error.message;
|
|
|
|
if (error.message.includes('CONFLICT')) {
|
|
errorMessage = 'Merge conflicts detected';
|
|
details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
|
|
} else if (error.message.includes('Please commit your changes or stash them')) {
|
|
errorMessage = 'Uncommitted changes detected';
|
|
details = 'Please commit or stash your local changes before pulling.';
|
|
} else if (error.message.includes('Could not resolve hostname')) {
|
|
errorMessage = 'Network error';
|
|
details = 'Unable to connect to remote repository. Check your internet connection.';
|
|
} else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
|
|
errorMessage = 'Remote not configured';
|
|
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
|
|
} else if (error.message.includes('diverged')) {
|
|
errorMessage = 'Branches have diverged';
|
|
details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: errorMessage,
|
|
details: details
|
|
});
|
|
}
|
|
});
|
|
|
|
// Push commits to remote repository
|
|
router.post('/push', async (req, res) => {
|
|
const { project } = req.body;
|
|
|
|
if (!project) {
|
|
return res.status(400).json({ error: 'Project id is required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Get current branch and its upstream remote
|
|
const branch = await getCurrentBranchName(projectPath);
|
|
|
|
let remoteName = 'origin'; // fallback
|
|
let remoteBranch = branch; // fallback
|
|
try {
|
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
|
const tracking = stdout.trim();
|
|
remoteName = tracking.split('/')[0]; // Extract remote name
|
|
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
|
} catch (error) {
|
|
// No upstream, use fallback
|
|
console.log('No upstream configured, using origin/branch as fallback');
|
|
}
|
|
|
|
validateRemoteName(remoteName);
|
|
validateBranchName(remoteBranch);
|
|
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
|
|
|
|
res.json({
|
|
success: true,
|
|
output: stdout || 'Push completed successfully',
|
|
remoteName,
|
|
remoteBranch
|
|
});
|
|
} catch (error) {
|
|
console.error('Git push error:', error);
|
|
|
|
// Enhanced error handling for common push scenarios
|
|
let errorMessage = 'Push failed';
|
|
let details = error.message;
|
|
|
|
if (error.message.includes('rejected')) {
|
|
errorMessage = 'Push rejected';
|
|
details = 'The remote has newer commits. Pull first to merge changes before pushing.';
|
|
} else if (error.message.includes('non-fast-forward')) {
|
|
errorMessage = 'Non-fast-forward push';
|
|
details = 'Your branch is behind the remote. Pull the latest changes first.';
|
|
} else if (error.message.includes('Could not resolve hostname')) {
|
|
errorMessage = 'Network error';
|
|
details = 'Unable to connect to remote repository. Check your internet connection.';
|
|
} else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
|
|
errorMessage = 'Remote not configured';
|
|
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
|
|
} else if (error.message.includes('Permission denied')) {
|
|
errorMessage = 'Authentication failed';
|
|
details = 'Permission denied. Check your credentials or SSH keys.';
|
|
} else if (error.message.includes('no upstream branch')) {
|
|
errorMessage = 'No upstream branch';
|
|
details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: errorMessage,
|
|
details: details
|
|
});
|
|
}
|
|
});
|
|
|
|
// Publish branch to remote (set upstream and push)
|
|
router.post('/publish', async (req, res) => {
|
|
const { project, branch } = req.body;
|
|
|
|
if (!project || !branch) {
|
|
return res.status(400).json({ error: 'Project id and branch are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Validate branch name
|
|
validateBranchName(branch);
|
|
|
|
// Get current branch to verify it matches the requested branch
|
|
const currentBranchName = await getCurrentBranchName(projectPath);
|
|
|
|
if (currentBranchName !== branch) {
|
|
return res.status(400).json({
|
|
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
|
});
|
|
}
|
|
|
|
// Check if remote exists
|
|
let remoteName = 'origin';
|
|
try {
|
|
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
|
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
|
if (remotes.length === 0) {
|
|
return res.status(400).json({
|
|
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
|
});
|
|
}
|
|
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
} catch (error) {
|
|
return res.status(400).json({
|
|
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
|
});
|
|
}
|
|
|
|
// Publish the branch (set upstream and push)
|
|
validateRemoteName(remoteName);
|
|
const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
|
|
|
|
res.json({
|
|
success: true,
|
|
output: stdout || 'Branch published successfully',
|
|
remoteName,
|
|
branch
|
|
});
|
|
} catch (error) {
|
|
console.error('Git publish error:', error);
|
|
|
|
// Enhanced error handling for common publish scenarios
|
|
let errorMessage = 'Publish failed';
|
|
let details = error.message;
|
|
|
|
if (error.message.includes('rejected')) {
|
|
errorMessage = 'Publish rejected';
|
|
details = 'The remote branch already exists and has different commits. Use push instead.';
|
|
} else if (error.message.includes('Could not resolve hostname')) {
|
|
errorMessage = 'Network error';
|
|
details = 'Unable to connect to remote repository. Check your internet connection.';
|
|
} else if (error.message.includes('Permission denied')) {
|
|
errorMessage = 'Authentication failed';
|
|
details = 'Permission denied. Check your credentials or SSH keys.';
|
|
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
|
|
errorMessage = 'Remote not configured';
|
|
details = 'Remote repository not properly configured. Check your remote URL.';
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: errorMessage,
|
|
details: details
|
|
});
|
|
}
|
|
});
|
|
|
|
// Discard changes for a specific file
|
|
router.post('/discard', async (req, res) => {
|
|
const { project, file } = req.body;
|
|
|
|
if (!project || !file) {
|
|
return res.status(400).json({ error: 'Project id and file path are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
const {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath,
|
|
} = await resolveRepositoryFilePath(projectPath, file);
|
|
|
|
// Check file status to determine correct discard command
|
|
const { stdout: statusOutput } = await spawnAsync(
|
|
'git',
|
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
|
|
if (!statusOutput.trim()) {
|
|
return res.status(400).json({ error: 'No changes to discard for this file' });
|
|
}
|
|
|
|
const status = statusOutput.substring(0, 2);
|
|
|
|
if (status === '??') {
|
|
// Untracked file or directory - delete it
|
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
await fs.rm(filePath, { recursive: true, force: true });
|
|
} else {
|
|
await fs.unlink(filePath);
|
|
}
|
|
} else if (status.includes('M') || status.includes('D')) {
|
|
// Modified or deleted file - restore from HEAD
|
|
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
} else if (status.includes('A')) {
|
|
// Added file - unstage it
|
|
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
}
|
|
|
|
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
|
|
} catch (error) {
|
|
console.error('Git discard error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete untracked file
|
|
router.post('/delete-untracked', async (req, res) => {
|
|
const { project, file } = req.body;
|
|
|
|
if (!project || !file) {
|
|
return res.status(400).json({ error: 'Project id and file path are required' });
|
|
}
|
|
|
|
try {
|
|
const projectPath = await getActualProjectPath(project);
|
|
await validateGitRepository(projectPath);
|
|
const {
|
|
repositoryRootPath,
|
|
repositoryRelativeFilePath,
|
|
} = await resolveRepositoryFilePath(projectPath, file);
|
|
|
|
// Check if file is actually untracked
|
|
const { stdout: statusOutput } = await spawnAsync(
|
|
'git',
|
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
{ cwd: repositoryRootPath },
|
|
);
|
|
|
|
if (!statusOutput.trim()) {
|
|
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
|
}
|
|
|
|
const status = statusOutput.substring(0, 2);
|
|
|
|
if (status !== '??') {
|
|
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
|
}
|
|
|
|
// Delete the untracked file or directory
|
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Use rm with recursive option for directories
|
|
await fs.rm(filePath, { recursive: true, force: true });
|
|
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
|
|
} else {
|
|
await fs.unlink(filePath);
|
|
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
|
|
}
|
|
} catch (error) {
|
|
console.error('Git delete untracked error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
export default router;
|