mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 18:28:38 +00:00
* refactor: remove unused exports
* refactor: remove unused fields from project and session objects
* refactor: rename session_names table and related code to sessions for clarity and consistency
* refactor(database): move db into typescript
- Implemented githubTokensDb for managing GitHub tokens with CRUD operations.
- Created
otificationPreferencesDb to handle user notification preferences.
- Added projectsDb for project path management and related operations.
- Introduced pushSubscriptionsDb for managing browser push subscriptions.
- Developed scanStateDb to track the last scanned timestamp.
- Established sessionsDb for session management with CRUD functionalities.
- Created userDb for user management, including authentication and onboarding.
- Implemented apidKeysDb for storing and managing VAPID keys.
feat(database): define schema for new database tables
- Added SQL schema definitions for users, API keys, user credentials, notification preferences, VAPID keys, push subscriptions, projects, sessions, scan state, and app configuration.
- Included necessary indexes for performance optimization.
refactor(shared): enhance type definitions and utility functions
- Updated shared types and interfaces for improved clarity and consistency.
- Added new types for credential management and provider-specific operations.
- Refined utility functions for better error handling and message normalization.
* feat: added session indexer logic
* perf(projects): lazy-load TaskMaster metadata per selected project
Why:
- /api/projects is a hot path (initial load, sidebar refresh, websocket sync).
- Scanning .taskmaster for every project on each call added avoidable fs I/O and payload size.
- TaskMaster metadata is only needed after selecting a specific project.
- Moving it to a project-scoped endpoint makes loading cost match user intent.
- The UI now hydrates TaskMaster state on selection and keeps it across refresh events.
- This prevents status flicker/regression while still removing global scan overhead.
- Selection fetches are sequence-guarded to block stale async responses on fast switching.
- isManuallyAdded was removed from responses to keep the public project contract minimal.
- Project dumps now use incrementing snapshot files to preserve history for debugging.
What changed:
- Added GET /api/projects/:projectName/taskmaster and getProjectTaskMaster().
- Removed TaskMaster detection from bulk getProjects().
- Added api.projectTaskmaster(...) plus selection-time hydration in frontend contexts.
- Merged cached taskmaster values into refreshed project lists for continuity.
- Removed isManuallyAdded from manual project payloads.
* refactor: update import paths for database modules and remove legacy db.js and schema.js files
* refactor(projects): identify projects by DB projectId instead of folder-derived name
GET /api/projects used to scan ~/.claude/projects/ on every request, derive
each project's identity from the encoded folder name, and re-parse JSONL
files to build session lists. Using the folder-derived name as the project
identifier leaked the Claude CLI's on-disk encoding into every API route,
forced every downstream endpoint to re-resolve a real path via JSONL
'cwd' inspection, and made the project list endpoint O(projects x sessions)
on disk I/O.
This change switches the entire API surface to identify projects by the
stable primary key from the 'projects' table and drives the listing
straight from the DB:
- Add projectsDb.getProjectPathById as the canonical projectId -> path
resolver so routes no longer need to touch the filesystem to figure out
where a project lives.
- Rewrite getProjects so it reads the project list from the 'projects'
table and the per-project session list from the 'sessions' table (one
SELECT per project). No filesystem scanning happens for this endpoint
anymore, which removes the dependency on ~/.claude/projects existing,
on Cursor's MD5-hashed chat folders being discoverable, and on Codex's
JSONL history being on disk. Per the migration spec each session now
exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0
(message counting is not implemented), and sessionMeta.hasMore is
pinned to false since this endpoint doesn't drive session pagination.
- Introduce id-based wrappers (getSessionsById, renameProjectById,
deleteSessionById, deleteProjectById, getProjectTaskMasterById) so
every caller can pass projectId and resolve the real path through the
DB. renameProjectById also writes to projects.custom_project_name so
the DB-driven getProjects response reflects renames immediately; it
keeps project-config.json in sync for any legacy reader that still
consults the JSON file.
- Migrate every /api/projects/:projectName route in server/index.js,
server/routes/taskmaster.js, and server/routes/messages.js to
:projectId, and change server/routes/git.js so the 'project'
query/body parameter carries a projectId that is resolved through the
DB before any git command runs. TaskMaster WebSocket broadcasts emit
'projectId' for the same reason so the frontend can match
notifications against its current selection without another lookup.
- Delete helpers that existed only to feed the old getProjects path
(getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along
with their unused imports (better-sqlite3's Database,
applyCustomSessionNames). The legacy folder-name helpers (getSessions,
renameProject, deleteSession, deleteProject, extractProjectDirectory)
are kept as internal implementation details of the id-based wrappers
and of destructive cleanup / conversation search, but they are no
longer re-exported.
- searchConversations still walks JSONL to produce match snippets (that
data doesn't live in the DB), but it now includes the resolved
projectId in each result so the sidebar can cross-reference hits with
its already loaded project list without a second round-trip.
Frontend migration:
- Project.name is replaced by Project.projectId in src/types/app.ts, and
ProjectSession.__projectName becomes __projectId so session tagging
and sidebar state keys stay aligned with the backend identifier.
Settings continues to use SettingsProject.name for legacy consumers,
but it is populated from projectId by normalizeProjectForSettings.
- All places that previously indexed per-project state by project.name
(sidebar expanded/starred/loading/deletingProjects sets,
additionalSessions map, projectHasMoreOverrides, starredProjects
localStorage, command history and draft-input localStorage,
TaskMaster caches) now key on projectId so state survives
display-name edits and is consistent across the app.
- src/utils/api.js renames every endpoint parameter to projectId, the
unified messages endpoint takes projectId in its query string, and
useSessionStore forwards projectId on fetchFromServer / fetchMore /
refreshFromServer. Git panel, file tree, code editor, PRD editor,
plugins context, MCP server flows and TaskMaster hooks are all
updated to pass projectId.
- DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default'
projectId sentinel so the empty-shell placeholder still satisfies the
Project contract.
Bug fix bundled in:
- sessionsDb.setName no longer bumps updated_at when a row already
exists. Renaming is a label change, not activity, so there is no
reason for it to reset 'last activity' in the sidebar. It also no
longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive
'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and
caused renamed sessions to appear shifted backwards by the client's
UTC offset. When an INSERT actually happens it now writes ISO-8601
UTC with a 'Z' suffix.
- buildSessionsByProviderFromDb normalizes any legacy naive timestamps
in the sessions table to ISO-8601 UTC on the way out so rows written
before this change also render correctly on the client.
Other cleanup:
- Removed the filesystem-first project-discovery comment block at the
top of server/projects.js and replaced it with a short note that
describes the new DB-driven flow and lists the few remaining
filesystem-dependent helpers (message reads, search, destructive
delete, manual project registration).
- server/modules/providers/index.ts is added as a small barrel so the
providers module exposes a stable public surface.
Made-with: Cursor
* refactor(projects): reorganize project-related logic into dedicated modules
* refactor(projects): rename getProjects with getProjectsWithSessions
* refactor: update import path for getProjectsWithSessions to include file extension
* refactor: use updated session watcher
In addition, for projects_updated websocket response, send the sessionId instead
* refactor(websocket): move websocket logic to its own module
* refactor(sessions-watcher): remove redundant logging after session sync completion
* refactor(index.js): reorganize code structure
* refactor(index.js): fix import order
* refactor: remove unnecessary GitHub cloning logic from create-workspace endpoint
* refactor: modularize project services, and wizard create/clone flow
Restructure project creation, listing, GitHub clone progress, and TaskMaster
details behind a dedicated TypeScript module under server/modules/projects/,
and align the client wizard with a single path-based flow.
Server / routing
- Remove server/routes/projects.js and mount server/modules/projects/
projects.routes.ts at /api/projects (still behind authenticateToken).
- Drop duplicate handlers from server/index.js for GET /api/projects and
GET /api/projects/:projectId/taskmaster; those live on the new router.
- Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in
index.js instead of the deleted projects route module.
Projects router (projects.routes.ts)
- GET /: list projects with sessions (existing snapshot behavior).
- POST /create-project: validate body, reject legacy workspaceType and
mixed clone fields, delegate to createProject service, return distinct
success copy when an archived path is reactivated.
- GET /clone-progress: Server-Sent Events for clone progress/complete/error;
requires authenticated user id for token resolution; wires startCloneProject.
- GET /:projectId/taskmaster: delegates to getProjectTaskMaster.
Services (new)
- project-management.service.ts: path validation, workspace directory
creation, persistence via projectsDb.createProjectPath, mapping to API
project shape; surfaces AppError for validation, conflict, and not-found
cases; optional dependency injection for tests.
- project-clone.service.ts: validates workspace, resolves GitHub auth
(stored token or inline token), runs git clone with progress callbacks,
registers project via createProject on success; sanitizes errors and
supports cancellation; injectable dependencies for tests.
- projects-has-taskmaster.service.ts: moves TaskMaster detection and
normalization out of server/projects.js; resolve-by-id and public
getProjectTaskMaster with structured AppError responses.
Persistence and shared types
- projectsDb.createProjectPath now returns CreateProjectPathResult
(created | reactivated_archived | active_conflict) using INSERT … ON
CONFLICT with selective update when the row is archived; normalizes
display name from path or custom name; repository row typing moves to
shared ProjectRepositoryRow.
- getProjectPaths() returns only non-archived rows (isArchived = 0).
- shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome,
WorkspacePathValidationResult.
- shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath,
asyncHandler for Express async routes.
Legacy cleanup
- server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo,
and getProjectTaskMasterById (logic lives in the new service).
- server/routes/agent.js: register external API project paths with
projectsDb.createProjectPath instead of addProjectManually try/catch;
treat active_conflict as an existing registration and continue.
Tests
- Add Node test suites for project-management, project-clone, and
projects-has-taskmaster services; update projects.service test import
for renamed projects-with-sessions-fetch.service.ts.
Rename
- projects.service.ts → projects-with-sessions-fetch.service.ts;
re-export from modules/projects/index.ts.
Client (project creation wizard)
- Remove StepTypeSelection and workspaceType from form state and types;
wizard is two steps (configure path/GitHub auth, then review).
- createWorkspaceRequest → createProjectRequest; clone vs create-only
inferred from githubUrl (pathUtils / isCloneWorkflow).
- Adjust step indices, WizardProgress, StepConfiguration/Review,
WorkspacePathField, and src/utils/api.js as needed for the new API.
Docs
- Minor websocket README touch-up.
Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is
centralized in typed services with explicit HTTP errors and test seams.
* refactor: remove loading sessions logic from sidebar
* refactor: move project rename to module
* refactor: move project deletion to module
* refactor: move project star state from localStorage to backend
* refactor: implement optimistic UI for project star state management
* feat: optimistic update for session watcher
* fix(projects-state): stop websocket message reprocessing loop
The websocket projects effect in useProjectsState could re-handle the same
latestMessage after local state writes triggered re-renders.
Under bursty websocket traffic, this created an update feedback cycle
that surfaced as 'Maximum update depth exceeded', often from Sidebar.
What changed:
- Added lastHandledMessageRef so each latestMessage object is handled once.
- Added an early return guard when the current message was already handled.
- Made projects updates idempotent by comparing previous and merged payloads
before calling setProjects.
Result:
- Breaks the effect -> state update -> effect re-entry cycle.
- Reduces redundant renders during rapid projects_updated traffic while
preserving normal project/session synchronization.
* refactor: optimize project auto-expand logic
* refactor: move projects provider specific logic into respective session providers
* refactor: move rename and delete sessions to modules
* refactor: move fetching messages to module
* fix: remove unused var
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* Potential fix for pull request finding 'Useless assignment to local variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* refactor(projects/sidebar): remove temp snapshot side-effects and simplify session metadata UX
Why this change was needed:
- Project listing had an implicit side effect: every fetch wrote a debug snapshot under `.tmp/project-dumps`.
That added unnecessary disk I/O to a hot path, introduced hidden runtime behavior, and created maintenance
overhead for code that was not part of product functionality.
- Keeping snapshot-specific exports/tests around made the projects module API broader than needed and coupled
tests to temporary/debug behavior instead of user-visible behavior.
- Codex sessions could remain stuck with a placeholder name (`Untitled Codex Session`) even after a real title
became available from newer sync data, which degraded session discoverability in the UI.
- Sidebar session rows showed duplicated provider branding and long-form relative times, which added visual noise
and reduced scan speed when many sessions are listed.
What changed:
- Removed temporary projects snapshot dumping from `projects-with-sessions-fetch.service.ts`:
- deleted snapshot types/helpers and file-write flow
- removed the write call from `getProjectsWithSessions`
- Removed snapshot-related surface area from `projects/index.ts`.
- Removed the snapshot-focused test `projects.service.test.ts` that only validated removed debug behavior.
- Updated `codex-session-synchronizer.provider.ts` to upgrade session names when an existing session still has
the placeholder title but a real parsed name is now available.
- Updated `SidebarSessionItem.tsx`:
- removed duplicate provider logo rendering in each session row
- moved age indicator to the right side
- made age indicator fade on hover to prioritize action controls
- switched to compact relative time format (`<1m`, `Xm`, `Xhr`, `Xd`) for faster list scanning
Outcome:
- Lower overhead and fewer hidden side effects in project fetches.
- Cleaner module boundaries in projects.
- Better Codex session naming consistency after sync.
- Cleaner sidebar density and clearer hover/action behavior.
* refactor: implement pagination for project sessions loading
* refactor: move search to module
* fix: search performance
* refactor: add handling for internal Codex metadata in conversation search
* fix(migrations,projects,clone): normalize legacy schema before writes and harden conflict detection
Why
- Legacy installs can have a sessions table shape that predates provider/custom_name columns. Running migrateLegacySessionNames first caused its INSERT OR REPLACE INTO sessions (...) to target columns that may not exist and fail during startup migration.
- Some upgraded databases had projects.project_id as plain TEXT instead of a real PRIMARY KEY. That breaks assumptions used by id-based lookups and can allow invalid/duplicate identity semantics over time.
- projectsDb.createProjectPath inferred outcomes from
ow.isArchived, but the upsert path always returns the post-update row with isArchived=0, so archived-reactivation and fresh-create could be misclassified.
- git clone accepted user-controlled URLs directly in argv position, so inputs beginning with - could be interpreted as options instead of a repository argument.
What
- Added
ebuildProjectsTableWithPrimaryKeySchema in migrations: detect table shape via getTableInfo('projects'), verify project_id has pk=1, and rebuild when missing.
- Rebuild flow now creates a canonical projects__new table (project_id TEXT PRIMARY KEY), copies rows with transformation, backfills empty ids via SQLITE_UUID_SQL, deduplicates conflicting ids/paths, then swaps tables inside a transaction.
- Replaced the prior ddColumnToTableIfNotExists(...) + UPDATE project_id sequence with PK-aware detection/rebuild logic so legacy DBs converge to the required schema.
- Reordered migration sequence to run
ebuildSessionsTableWithProjectSchema before migrateLegacySessionNames, ensuring sessions is normalized before legacy session_names merge writes execute.
- Updated projectsDb.createProjectPath to generate an ttemptedId before insert, pass it into the prepared statement, and classify outcomes by comparing returned
ow.project_id to ttemptedId (created vs
eactivated_archived), with no-row remaining ctive_conflict.
- Hardened clone execution by inserting -- before clone URL in git argv and rejecting normalized GitHub URLs that start with - in startCloneProject.
Tests
- Added integration coverage for projectsDb.createProjectPath branches: fresh insert, archived reactivation, and active conflict.
- Added clone service test for option-prefixed githubUrl rejection (INVALID_GITHUB_URL).
* refactor(session-synchronizer): update last scanned timestamp based on synchronization results
* refactor: improve session limit and offset validation in provider routes
* refactor: normalize project paths across database and service modules
* refactor(database): make session id the primary key in sessions table
* fix(codex): preserve reasoning entries as thinking blocks
Codex history normalization was downgrading reasoning into plain assistant text
because of branch ordering, not because the raw data was missing.
Why this mattered:
- Codex reasoning JSONL entries are intentionally mapped to history items with
type thinking, but they also carry message.role assistant.
- normalizeHistoryEntry evaluated the assistant-role branch before the
thinking branch.
- As a result, reasoning content matched the assistant-text path first and was
emitted as kind text instead of kind thinking.
- This collapses semantic intent, so UI and downstream features that rely on
thinking blocks (separate rendering, filtering, and interpretation of model
thought process vs final answer) receive the wrong message kind.
What changed:
- Prioritized thinking detection (raw.type === thinking or raw.isReasoning)
before role-based assistant normalization.
- Kept a non-empty content guard for thinking payloads to avoid emitting empty
artifacts.
Impact:
- Reasoning entries from persisted Codex JSONL now remain thinking blocks
end-to-end.
- Regular assistant text normalization behavior remains unchanged.
* refactor: remove dead code
* refactor: directly use getProjectPathById from projectsDb
* refactor: add gemini jsonl session support
1494 lines
50 KiB
JavaScript
Executable File
1494 lines
50 KiB
JavaScript
Executable File
import express from 'express';
|
|
import { spawn } from 'child_process';
|
|
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
|
|
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);
|
|
|
|
// Get git status
|
|
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
|
|
|
const modified = [];
|
|
const added = [];
|
|
const deleted = [];
|
|
const untracked = [];
|
|
|
|
statusOutput.split('\n').forEach(line => {
|
|
if (!line.trim()) return;
|
|
|
|
const status = line.substring(0, 2);
|
|
const file = line.substring(3);
|
|
|
|
if (status === 'M ' || status === ' M' || status === 'MM') {
|
|
modified.push(file);
|
|
} else if (status === 'A ' || status === 'AM') {
|
|
added.push(file);
|
|
} else if (status === 'D ' || status === ' D') {
|
|
deleted.push(file);
|
|
} else if (status === '??') {
|
|
untracked.push(file);
|
|
}
|
|
});
|
|
|
|
res.json({
|
|
branch,
|
|
hasCommits,
|
|
modified,
|
|
added,
|
|
deleted,
|
|
untracked
|
|
});
|
|
} 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 });
|
|
}
|
|
});
|
|
|
|
// 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 });
|
|
}
|
|
});
|
|
|
|
// Get recent commits
|
|
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;
|
|
|
|
// Get commit log with stats
|
|
const { stdout } = await spawnAsync(
|
|
'git',
|
|
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
|
|
{ cwd: projectPath },
|
|
);
|
|
|
|
const commits = stdout
|
|
.split('\n')
|
|
.filter(line => line.trim())
|
|
.map(line => {
|
|
const [hash, author, email, date, ...messageParts] = line.split('|');
|
|
return {
|
|
hash,
|
|
author,
|
|
email,
|
|
date,
|
|
message: messageParts.join('|')
|
|
};
|
|
});
|
|
|
|
// Get stats for each commit
|
|
for (const commit of commits) {
|
|
try {
|
|
const { stdout: stats } = await spawnAsync(
|
|
'git', ['show', '--stat', '--format=', commit.hash],
|
|
{ cwd: projectPath }
|
|
);
|
|
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
|
} catch (error) {
|
|
commit.stats = '';
|
|
}
|
|
}
|
|
|
|
res.json({ commits });
|
|
} 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;
|