feat(KB-266): overhaul GitManagerModal with new architecture and API
- Completely rewrite GitManagerModal with modern React architecture - Add comprehensive CSS styling for git operations UI - Implement new API endpoints for git management operations - Add server-side routes for git command handling - Fix shell injection vulnerability in git command execution - Update tests with complete API mocking coverage - Add changeset documenting the overhaul
This commit is contained in:
5
.changeset/git-manager-overhaul.md
Normal file
5
.changeset/git-manager-overhaul.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Overhaul the Git Manager modal with interactive file staging, committing, stash management, commit search, branch filtering, and a responsive multi-panel layout with dark/light theme support.
|
||||||
@@ -709,6 +709,92 @@ export function pushBranch(): Promise<GitPushResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Git stash entry */
|
||||||
|
export interface GitStash {
|
||||||
|
index: number;
|
||||||
|
message: string;
|
||||||
|
date: string;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual file change with staging status */
|
||||||
|
export interface GitFileChange {
|
||||||
|
file: string;
|
||||||
|
status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked";
|
||||||
|
staged: boolean;
|
||||||
|
oldFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch stash list */
|
||||||
|
export function fetchGitStashList(): Promise<GitStash[]> {
|
||||||
|
return api<GitStash[]>("/git/stashes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new stash */
|
||||||
|
export function createStash(message?: string): Promise<{ message: string }> {
|
||||||
|
return api<{ message: string }>("/git/stashes", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a stash entry */
|
||||||
|
export function applyStash(index: number, drop?: boolean): Promise<{ message: string }> {
|
||||||
|
return api<{ message: string }>(`/git/stashes/${index}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ drop }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a stash entry */
|
||||||
|
export function dropStash(index: number): Promise<{ message: string }> {
|
||||||
|
return api<{ message: string }>(`/git/stashes/${index}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch unstaged diff (working directory changes) */
|
||||||
|
export function fetchUnstagedDiff(): Promise<{ stat: string; patch: string }> {
|
||||||
|
return api<{ stat: string; patch: string }>("/git/diff");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch file changes (staged and unstaged) */
|
||||||
|
export function fetchFileChanges(): Promise<GitFileChange[]> {
|
||||||
|
return api<GitFileChange[]>("/git/changes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage specific files */
|
||||||
|
export function stageFiles(files: string[]): Promise<{ staged: string[] }> {
|
||||||
|
return api<{ staged: string[] }>("/git/stage", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ files }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unstage specific files */
|
||||||
|
export function unstageFiles(files: string[]): Promise<{ unstaged: string[] }> {
|
||||||
|
return api<{ unstaged: string[] }>("/git/unstage", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ files }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a commit */
|
||||||
|
export function createCommit(message: string): Promise<{ hash: string; message: string }> {
|
||||||
|
return api<{ hash: string; message: string }>("/git/commit", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard changes in working directory for specific files */
|
||||||
|
export function discardChanges(files: string[]): Promise<{ discarded: string[] }> {
|
||||||
|
return api<{ discarded: string[] }>("/git/discard", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ files }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- File Browser API ---
|
// --- File Browser API ---
|
||||||
|
|
||||||
/** File node in directory listing */
|
/** File node in directory listing */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -563,6 +563,253 @@ function pushGitBranch(): GitPushResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Git Stash, Stage, Commit Helper Functions ────────────────────────────
|
||||||
|
|
||||||
|
/** Git stash entry */
|
||||||
|
export interface GitStash {
|
||||||
|
index: number;
|
||||||
|
message: string;
|
||||||
|
date: string;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual file change with staging status */
|
||||||
|
export interface GitFileChange {
|
||||||
|
file: string;
|
||||||
|
status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked";
|
||||||
|
staged: boolean;
|
||||||
|
oldFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of stash entries.
|
||||||
|
*/
|
||||||
|
function getGitStashList(): GitStash[] {
|
||||||
|
try {
|
||||||
|
const output = execSync('git stash list --format="%gd|%gs|%ai"', {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
if (!output) return [];
|
||||||
|
|
||||||
|
const stashes: GitStash[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const parts = line.split("|");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const [ref, message, date] = parts;
|
||||||
|
const indexMatch = ref.match(/stash@\{(\d+)\}/);
|
||||||
|
const index = indexMatch ? parseInt(indexMatch[1], 10) : stashes.length;
|
||||||
|
// Extract branch from message like "WIP on main: abc1234 ..."
|
||||||
|
const branchMatch = message.match(/(?:WIP on|On) ([^:]+):/);
|
||||||
|
const branch = branchMatch ? branchMatch[1] : "";
|
||||||
|
stashes.push({ index, message, date, branch });
|
||||||
|
}
|
||||||
|
return stashes;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new stash.
|
||||||
|
*/
|
||||||
|
function createGitStash(message?: string): string {
|
||||||
|
let output: string;
|
||||||
|
if (message) {
|
||||||
|
// Sanitize message: remove shell metacharacters to prevent injection
|
||||||
|
const sanitized = message.replace(/[`$\\!"]/g, "").trim();
|
||||||
|
if (!sanitized) {
|
||||||
|
throw new Error("Invalid stash message");
|
||||||
|
}
|
||||||
|
output = execSync(`git stash push -m '${sanitized.replace(/'/g, "'\\''")}'`, { encoding: "utf-8", timeout: 10000 }).trim();
|
||||||
|
} else {
|
||||||
|
output = execSync("git stash push", { encoding: "utf-8", timeout: 10000 }).trim();
|
||||||
|
}
|
||||||
|
if (output.includes("No local changes to save")) {
|
||||||
|
throw new Error("No local changes to stash");
|
||||||
|
}
|
||||||
|
return output || "Stash created";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a stash entry.
|
||||||
|
*/
|
||||||
|
function applyGitStash(index: number, drop: boolean = false): string {
|
||||||
|
if (index < 0 || !Number.isInteger(index)) throw new Error("Invalid stash index");
|
||||||
|
const cmd = drop ? `git stash pop stash@{${index}}` : `git stash apply stash@{${index}}`;
|
||||||
|
const output = execSync(cmd, { encoding: "utf-8", timeout: 10000 }).trim();
|
||||||
|
return output || (drop ? "Stash popped" : "Stash applied");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a stash entry.
|
||||||
|
*/
|
||||||
|
function dropGitStash(index: number): string {
|
||||||
|
if (index < 0 || !Number.isInteger(index)) throw new Error("Invalid stash index");
|
||||||
|
const output = execSync(`git stash drop stash@{${index}}`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 10000,
|
||||||
|
}).trim();
|
||||||
|
return output || "Stash dropped";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file changes (staged and unstaged).
|
||||||
|
*/
|
||||||
|
function getGitFileChanges(): GitFileChange[] {
|
||||||
|
try {
|
||||||
|
const output = execSync("git status --porcelain=v1", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
if (!output) return [];
|
||||||
|
|
||||||
|
const changes: GitFileChange[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (line.length < 3) continue;
|
||||||
|
const indexStatus = line[0];
|
||||||
|
const workTreeStatus = line[1];
|
||||||
|
const filePath = line.slice(3).trim();
|
||||||
|
|
||||||
|
// Map git status codes to our status type
|
||||||
|
const mapStatus = (code: string): GitFileChange["status"] => {
|
||||||
|
switch (code) {
|
||||||
|
case "A": return "added";
|
||||||
|
case "M": return "modified";
|
||||||
|
case "D": return "deleted";
|
||||||
|
case "R": return "renamed";
|
||||||
|
case "C": return "copied";
|
||||||
|
case "?": return "untracked";
|
||||||
|
default: return "modified";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle renamed files: "R old -> new"
|
||||||
|
let file = filePath;
|
||||||
|
let oldFile: string | undefined;
|
||||||
|
if (filePath.includes(" -> ")) {
|
||||||
|
const [old, newF] = filePath.split(" -> ");
|
||||||
|
oldFile = old.trim();
|
||||||
|
file = newF.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged changes (index status is not space and not ?)
|
||||||
|
if (indexStatus !== " " && indexStatus !== "?") {
|
||||||
|
changes.push({
|
||||||
|
file,
|
||||||
|
status: mapStatus(indexStatus),
|
||||||
|
staged: true,
|
||||||
|
oldFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unstaged changes (work tree status is not space)
|
||||||
|
if (workTreeStatus !== " ") {
|
||||||
|
changes.push({
|
||||||
|
file,
|
||||||
|
status: workTreeStatus === "?" ? "untracked" : mapStatus(workTreeStatus),
|
||||||
|
staged: false,
|
||||||
|
oldFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get working directory diff.
|
||||||
|
*/
|
||||||
|
function getGitWorkingDiff(): { stat: string; patch: string } {
|
||||||
|
try {
|
||||||
|
const stat = execSync("git diff --stat", { encoding: "utf-8", timeout: 10000 }).trim();
|
||||||
|
const patch = execSync("git diff", { encoding: "utf-8", timeout: 10000 });
|
||||||
|
return { stat, patch };
|
||||||
|
} catch {
|
||||||
|
return { stat: "", patch: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage specific files.
|
||||||
|
*/
|
||||||
|
function stageGitFiles(files: string[]): string[] {
|
||||||
|
if (!files.length) throw new Error("No files specified");
|
||||||
|
// Validate file paths - no shell metacharacters
|
||||||
|
for (const f of files) {
|
||||||
|
if (/[;&|`$(){}[\]\r\n]/.test(f)) {
|
||||||
|
throw new Error(`Invalid file path: ${f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const escaped = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" ");
|
||||||
|
execSync(`git add ${escaped}`, { encoding: "utf-8", timeout: 10000 });
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unstage specific files.
|
||||||
|
*/
|
||||||
|
function unstageGitFiles(files: string[]): string[] {
|
||||||
|
if (!files.length) throw new Error("No files specified");
|
||||||
|
for (const f of files) {
|
||||||
|
if (/[;&|`$(){}[\]\r\n]/.test(f)) {
|
||||||
|
throw new Error(`Invalid file path: ${f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const escaped = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" ");
|
||||||
|
execSync(`git reset HEAD ${escaped}`, { encoding: "utf-8", timeout: 10000 });
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a commit with staged changes.
|
||||||
|
*/
|
||||||
|
function createGitCommit(message: string): { hash: string; message: string } {
|
||||||
|
if (!message || !message.trim()) throw new Error("Commit message is required");
|
||||||
|
// Check there are staged changes
|
||||||
|
const staged = execSync("git diff --cached --name-only", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||||
|
if (!staged) throw new Error("No staged changes to commit");
|
||||||
|
// Sanitize: use single quotes and escape embedded single quotes for shell safety
|
||||||
|
const sanitized = message.trim().replace(/'/g, "'\\''");
|
||||||
|
execSync(`git commit -m '${sanitized}'`, { encoding: "utf-8", timeout: 10000 });
|
||||||
|
const hash = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||||
|
return { hash, message: message.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard changes for specific files.
|
||||||
|
*/
|
||||||
|
function discardGitChanges(files: string[]): string[] {
|
||||||
|
if (!files.length) throw new Error("No files specified");
|
||||||
|
for (const f of files) {
|
||||||
|
if (/[;&|`$(){}[\]\r\n]/.test(f)) {
|
||||||
|
throw new Error(`Invalid file path: ${f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Separate untracked from tracked
|
||||||
|
const statusOutput = execSync("git status --porcelain=v1", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||||
|
const untracked = new Set<string>();
|
||||||
|
for (const line of statusOutput.split("\n")) {
|
||||||
|
if (line.startsWith("??")) {
|
||||||
|
untracked.add(line.slice(3).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trackedFiles = files.filter((f) => !untracked.has(f));
|
||||||
|
const untrackedFiles = files.filter((f) => untracked.has(f));
|
||||||
|
|
||||||
|
if (trackedFiles.length) {
|
||||||
|
const escaped = trackedFiles.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" ");
|
||||||
|
execSync(`git checkout -- ${escaped}`, { encoding: "utf-8", timeout: 10000 });
|
||||||
|
}
|
||||||
|
if (untrackedFiles.length) {
|
||||||
|
const escaped = untrackedFiles.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" ");
|
||||||
|
execSync(`git clean -f -- ${escaped}`, { encoding: "utf-8", timeout: 10000 });
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -1461,6 +1708,224 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Git Stash, Stage, Commit Routes ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/git/stashes
|
||||||
|
* Returns list of stash entries.
|
||||||
|
*/
|
||||||
|
router.get("/git/stashes", (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stashes = getGitStashList();
|
||||||
|
res.json(stashes);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/git/stashes
|
||||||
|
* Create a new stash.
|
||||||
|
* Body: { message?: string }
|
||||||
|
*/
|
||||||
|
router.post("/git/stashes", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { message } = req.body;
|
||||||
|
const result = createGitStash(message);
|
||||||
|
res.status(201).json({ message: result });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes("No local changes")) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/git/stashes/:index/apply
|
||||||
|
* Apply a stash entry.
|
||||||
|
* Body: { drop?: boolean }
|
||||||
|
*/
|
||||||
|
router.post("/git/stashes/:index/apply", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = parseInt(req.params.index, 10);
|
||||||
|
if (isNaN(index) || index < 0) {
|
||||||
|
res.status(400).json({ error: "Invalid stash index" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { drop } = req.body;
|
||||||
|
const result = applyGitStash(index, drop === true);
|
||||||
|
res.json({ message: result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/git/stashes/:index
|
||||||
|
* Drop a stash entry.
|
||||||
|
*/
|
||||||
|
router.delete("/git/stashes/:index", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = parseInt(req.params.index, 10);
|
||||||
|
if (isNaN(index) || index < 0) {
|
||||||
|
res.status(400).json({ error: "Invalid stash index" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = dropGitStash(index);
|
||||||
|
res.json({ message: result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/git/diff
|
||||||
|
* Returns working directory diff (unstaged changes).
|
||||||
|
*/
|
||||||
|
router.get("/git/diff", (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diff = getGitWorkingDiff();
|
||||||
|
res.json(diff);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/git/changes
|
||||||
|
* Returns file changes (staged and unstaged).
|
||||||
|
*/
|
||||||
|
router.get("/git/changes", (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changes = getGitFileChanges();
|
||||||
|
res.json(changes);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/git/stage
|
||||||
|
* Stage specific files.
|
||||||
|
* Body: { files: string[] }
|
||||||
|
*/
|
||||||
|
router.post("/git/stage", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { files } = req.body;
|
||||||
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
|
res.status(400).json({ error: "files array is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const staged = stageGitFiles(files);
|
||||||
|
res.json({ staged });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/git/unstage
|
||||||
|
* Unstage specific files.
|
||||||
|
* Body: { files: string[] }
|
||||||
|
*/
|
||||||
|
router.post("/git/unstage", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { files } = req.body;
|
||||||
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
|
res.status(400).json({ error: "files array is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unstaged = unstageGitFiles(files);
|
||||||
|
res.json({ unstaged });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/git/commit
|
||||||
|
* Create a commit with staged changes.
|
||||||
|
* Body: { message: string }
|
||||||
|
*/
|
||||||
|
router.post("/git/commit", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { message } = req.body;
|
||||||
|
if (!message || typeof message !== "string" || !message.trim()) {
|
||||||
|
res.status(400).json({ error: "Commit message is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = createGitCommit(message);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes("No staged changes")) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/git/discard
|
||||||
|
* Discard working directory changes for specific files.
|
||||||
|
* Body: { files: string[] }
|
||||||
|
*/
|
||||||
|
router.post("/git/discard", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isGitRepo()) {
|
||||||
|
res.status(400).json({ error: "Not a git repository" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { files } = req.body;
|
||||||
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
|
res.status(400).json({ error: "files array is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const discarded = discardGitChanges(files);
|
||||||
|
res.json({ discarded });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── GitHub Import Routes ──────────────────────────────────────────
|
// ── GitHub Import Routes ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user