From 834894690ea628aef91dd69c47d0e067b54b2a52 Mon Sep 17 00:00:00 2001 From: nanocubit <99839681+nanocubit@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:15:30 +0300 Subject: [PATCH] Prepare GitHub-ready description, README, and gitignore for GitForge --- gitforge/.devcontainer/devcontainer.json | 19 + gitforge/.gitignore | 39 ++ gitforge/Cargo.toml | 26 + gitforge/README.md | 67 +++ gitforge/REPOSITORY_DESCRIPTION.md | 1 + gitforge/package.json | 17 + gitforge/src-tauri/src/agent/mod.rs | 103 ++++ gitforge/src-tauri/src/main.rs | 43 ++ gitforge/src-tauri/src/mcp/server.rs | 637 ++++++++++++++++++++++ gitforge/src/bin/gitforge.rs | 81 +++ gitforge/src/components/GitLayout.vue | 83 +++ gitforge/src/components/TerminalPanel.vue | 90 +++ gitforge/src/components/ThemePanel.vue | 53 ++ gitforge/src/style/neumorphism.css | 4 + gitforge/src/style/themes.css | 7 + gitforge/tauri.conf.json | 19 + 16 files changed, 1289 insertions(+) create mode 100644 gitforge/.devcontainer/devcontainer.json create mode 100644 gitforge/.gitignore create mode 100644 gitforge/Cargo.toml create mode 100644 gitforge/README.md create mode 100644 gitforge/REPOSITORY_DESCRIPTION.md create mode 100644 gitforge/package.json create mode 100644 gitforge/src-tauri/src/agent/mod.rs create mode 100644 gitforge/src-tauri/src/main.rs create mode 100644 gitforge/src-tauri/src/mcp/server.rs create mode 100644 gitforge/src/bin/gitforge.rs create mode 100644 gitforge/src/components/GitLayout.vue create mode 100644 gitforge/src/components/TerminalPanel.vue create mode 100644 gitforge/src/components/ThemePanel.vue create mode 100644 gitforge/src/style/neumorphism.css create mode 100644 gitforge/src/style/themes.css create mode 100644 gitforge/tauri.conf.json diff --git a/gitforge/.devcontainer/devcontainer.json b/gitforge/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7a3881f --- /dev/null +++ b/gitforge/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "GitForge MCP IDE", + "image": "mcr.microsoft.com/devcontainers/rust:1-bullseye", + "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "20.18" }, + "ghcr.io/devcontainers/features/rust-debugger": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "tauri-apps.tauri-vscode", + "Vue.volar", + "ms-vscode.vscode-json" + ] + } + }, + "forwardPorts": [1420, 6767] +} diff --git a/gitforge/.gitignore b/gitforge/.gitignore new file mode 100644 index 0000000..9b81a5d --- /dev/null +++ b/gitforge/.gitignore @@ -0,0 +1,39 @@ +# Rust / Cargo +/target +**/*.rs.bk +Cargo.lock + +# Tauri +src-tauri/target +src-tauri/gen + +# Node / frontend +node_modules +dist +.vite +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# IDE / editor +.vscode +.idea +*.swp +*.swo +.DS_Store + +# Local runtime data +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +*.log +.env +.env.* + +# Test/temp artifacts +coverage +*.lcov +/tmp diff --git a/gitforge/Cargo.toml b/gitforge/Cargo.toml new file mode 100644 index 0000000..6d7a2f1 --- /dev/null +++ b/gitforge/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "gitforge" +version = "2.0.0" +edition = "2021" +description = "Forge your Git workflow — MCP Git IDE + AI Agent" + +[[bin]] +name = "gitforge" +path = "src/bin/gitforge.rs" + +[dependencies] +tauri = { version = "2.0", features = ["api-all", "webview"] } +git2 = "0.18" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] } +tokio = { version = "1.0", features = ["full"] } +async-tungstenite = "0.23" +rusqlite = { version = "0.31", features = ["bundled"] } +redb = "1.1" +clap = { version = "4.0", features = ["derive"] } +anyhow = "1.0" +futures-util = "0.3" + +[build-dependencies] +tauri-build = "2.0" diff --git a/gitforge/README.md b/gitforge/README.md new file mode 100644 index 0000000..312e3ba --- /dev/null +++ b/gitforge/README.md @@ -0,0 +1,67 @@ +# GitForge + +> Forge your Git workflow — MCP-native desktop IDE with local AI agent and terminal-first flow. + +GitForge is a compact desktop Git workspace that combines editor, terminal, PR management, browser panel, and MCP server in one app. The goal is to reduce context switching between VS Code, terminal, Git hosting UI, and AI assistant tools. + +## Repository Description (for GitHub) + +**Short description:** + +`MCP-native Git IDE (Tauri + Vue) with libgit2 terminal workflow, local voice agent, and 5-panel workspace.` + +## Core Features + +- **MCP JSON-RPC server** for Claude/Cursor/GPT integrations (`tools/list`, `git_status`, `git_commit`, PR/worktree methods). +- **Terminal Terminator direction**: git operations routed through `libgit2` backend APIs. +- **5-panel workspace UI**: Files, Editor, Terminal, PR panel, Browser panel. +- **Local BPGT agent memory** backed by `redb`. +- **SQLite metadata layer** for PRs and worktree tracking. + +## Tech Stack + +- **Desktop shell:** Tauri (Rust) +- **Backend:** Rust + git2 + rusqlite + redb + tokio/tungstenite +- **Frontend:** Vue 3 +- **Protocol:** MCP-style JSON-RPC over WebSocket + +## Current Status + +This repository is at **MVP foundation** stage: + +- backend MCP methods are implemented for core git/pr/worktree flows; +- UI is implemented as an interactive MVP shell; +- production hardening (security, full E2E, packaging/release pipeline) is still in progress. + +## Local Development + +### Prerequisites + +- Rust toolchain (stable) +- Node.js 20+ +- npm + +### Quick start + +```bash +# frontend deps +cd gitforge +npm install + +# rust format check +cargo fmt --all -- --check + +# tests (when crates.io access is available) +cargo test +``` + +## High-priority Next Steps + +1. Wire Vue panels to real Tauri `invoke` calls (remove mock data paths). +2. Add MCP contract/integration tests for all methods and edge-cases. +3. Implement secure command execution policy for terminal routing. +4. Add release pipeline with signed builds and staged rollout. + +## License + +TBD (set before public release). diff --git a/gitforge/REPOSITORY_DESCRIPTION.md b/gitforge/REPOSITORY_DESCRIPTION.md new file mode 100644 index 0000000..6989854 --- /dev/null +++ b/gitforge/REPOSITORY_DESCRIPTION.md @@ -0,0 +1 @@ +GitForge — MCP-native Git IDE built with Tauri + Vue: 5-column workspace (Files, Monaco, Terminal, PR, Browser), libgit2-powered terminal workflow, local BPGT voice agent, and JSON-RPC MCP server for Claude/Cursor/GPT integration. diff --git a/gitforge/package.json b/gitforge/package.json new file mode 100644 index 0000000..62caff2 --- /dev/null +++ b/gitforge/package.json @@ -0,0 +1,17 @@ +{ + "name": "gitforge-ui", + "version": "2.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "vue": "^3.4.0", + "xterm": "^5.5.0", + "xterm-addon-fit": "^0.10.0" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/gitforge/src-tauri/src/agent/mod.rs b/gitforge/src-tauri/src/agent/mod.rs new file mode 100644 index 0000000..2a22649 --- /dev/null +++ b/gitforge/src-tauri/src/agent/mod.rs @@ -0,0 +1,103 @@ +use redb::{Database, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; + +const MEMORY_TABLE: TableDefinition<&str, &str> = TableDefinition::new("agent_memory"); + +pub struct BpgtAgent { + db: Database, + model: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct AgentMemory { + pub context: String, + pub last_commands: Vec, + pub repo_state: String, +} + +#[derive(Debug, Clone, Copy)] +enum VoiceIntent { + Status, + Commit, + CreatePr, + ToolsList, +} + +impl BpgtAgent { + pub fn new(db_path: &str) -> Self { + let db = Database::create(db_path).expect("failed to create redb db"); + { + let write_txn = db.begin_write().expect("failed to begin write transaction"); + { + let _ = write_txn + .open_table(MEMORY_TABLE) + .expect("failed to open memory table"); + } + write_txn.commit().expect("failed to commit memory table creation"); + } + + Self { db, model: None } + } + + pub async fn process_voice(&self, text: &str) -> Result { + let _active_model = self.model.as_deref().unwrap_or("bgpt-fallback"); + let intent = self.parse_intent(text); + let method = match intent { + VoiceIntent::Status => "git_status", + VoiceIntent::Commit => "git_commit", + VoiceIntent::CreatePr => "git_create_pr", + VoiceIntent::ToolsList => "tools/list", + }; + + self.persist_last_command(method)?; + Ok(method.to_string()) + } + + fn parse_intent(&self, text: &str) -> VoiceIntent { + let lowered = text.to_lowercase(); + if lowered.contains("статус") || lowered.contains("status") { + VoiceIntent::Status + } else if lowered.contains("коммит") || lowered.contains("commit") { + VoiceIntent::Commit + } else if lowered.contains("пулреквест") || lowered.contains("pr") { + VoiceIntent::CreatePr + } else { + VoiceIntent::ToolsList + } + } + + fn persist_last_command(&self, command: &str) -> Result<(), String> { + let write_txn = self + .db + .begin_write() + .map_err(|e| format!("failed to begin write transaction: {e}"))?; + { + let mut table = write_txn + .open_table(MEMORY_TABLE) + .map_err(|e| format!("failed to open memory table: {e}"))?; + table + .insert("last_command", command) + .map_err(|e| format!("failed to save memory: {e}"))?; + } + write_txn + .commit() + .map_err(|e| format!("failed to commit memory: {e}")) + } + + pub fn last_command(&self) -> Result, String> { + let read_txn = self + .db + .begin_read() + .map_err(|e| format!("failed to begin read transaction: {e}"))?; + let table = read_txn + .open_table(MEMORY_TABLE) + .map_err(|e| format!("failed to open memory table: {e}"))?; + + let value = table + .get("last_command") + .map_err(|e| format!("failed to read memory: {e}"))? + .map(|v| v.value().to_string()); + + Ok(value) + } +} diff --git a/gitforge/src-tauri/src/main.rs b/gitforge/src-tauri/src/main.rs new file mode 100644 index 0000000..7048b84 --- /dev/null +++ b/gitforge/src-tauri/src/main.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +mod agent; +mod mcp { + pub mod server; +} + +use agent::BpgtAgent; +use mcp::server::GitForgeMcp; + +#[tauri::command] +async fn mcp_call(method: String, params: serde_json::Value, repo_path: String) -> Result { + let server = GitForgeMcp::new(repo_path)?; + let request = mcp::server::McpRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method, + params, + }; + + let response = { + let server = Arc::new(server); + server.execute_mcp_for_tauri(&request).await + }; + + match response.error { + Some(err) => Err(err.message), + None => Ok(response.result.unwrap_or_default()), + } +} + +#[tauri::command] +async fn voice_process(text: String, db_path: String) -> Result { + let agent = BpgtAgent::new(&db_path); + agent.process_voice(&text).await +} + +fn main() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![mcp_call, voice_process]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/gitforge/src-tauri/src/mcp/server.rs b/gitforge/src-tauri/src/mcp/server.rs new file mode 100644 index 0000000..05d8223 --- /dev/null +++ b/gitforge/src-tauri/src/mcp/server.rs @@ -0,0 +1,637 @@ +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct McpRequest { + pub jsonrpc: String, + pub id: serde_json::Value, + pub method: String, + pub params: serde_json::Value, +} + +#[derive(Serialize, Deserialize)] +pub struct McpResponse { + pub jsonrpc: String, + pub id: serde_json::Value, + pub result: Option, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct McpError { + pub code: i32, + pub message: String, +} + +pub struct GitForgeMcp { + repo_path: Arc, + db: Arc>, +} + +impl GitForgeMcp { + pub fn new(repo_path: String) -> Result { + let db_path = format!("{repo_path}/gitforge.db"); + let db = rusqlite::Connection::open(&db_path) + .map_err(|e| format!("failed to open sqlite db: {e}"))?; + + db.execute_batch( + "CREATE TABLE IF NOT EXISTS prs ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + from_branch TEXT, + to_branch TEXT, + state TEXT DEFAULT 'open', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS worktrees ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE, + path TEXT, + branch TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + );", + ) + .map_err(|e| format!("failed to initialize db: {e}"))?; + + Ok(Self { + repo_path: Arc::new(repo_path), + db: Arc::new(Mutex::new(db)), + }) + } + + pub async fn serve(self: Arc, host: String) -> Result { + let listener = TcpListener::bind(&host) + .await + .map_err(|e| format!("failed to bind MCP server: {e}"))?; + + println!("🤖 MCP Server listening on {host}"); + + while let Ok((stream, addr)) = listener.accept().await { + println!("MCP client connected: {addr}"); + let server = Arc::clone(&self); + tokio::spawn(async move { + if let Err(e) = server.handle_connection(stream).await { + eprintln!("MCP connection error: {e}"); + } + }); + } + + Ok("MCP server stopped".to_string()) + } + + async fn handle_connection(&self, stream: tokio::net::TcpStream) -> Result<(), String> { + let ws = accept_async(stream) + .await + .map_err(|e| format!("websocket handshake failed: {e}"))?; + + let (mut write, mut read) = ws.split(); + + while let Some(msg) = read.next().await { + let msg = msg.map_err(|e| format!("websocket read error: {e}"))?; + if let Message::Text(text) = msg { + let response = match serde_json::from_str::(&text) { + Ok(req) => self.execute_mcp(&req).await, + Err(e) => McpResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::Value::Null, + result: None, + error: Some(McpError { + code: -32700, + message: format!("parse error: {e}"), + }), + }, + }; + + let response_text = serde_json::to_string(&response) + .map_err(|e| format!("response serialization error: {e}"))?; + + write + .send(Message::Text(response_text)) + .await + .map_err(|e| format!("websocket send error: {e}"))?; + } + } + + Ok(()) + } + + async fn execute_mcp(&self, req: &McpRequest) -> McpResponse { + let result = match req.method.as_str() { + "tools/list" => self.tools_list(), + "git_status" => self.git_status(), + "git_commit" => self.git_commit(&req.params), + "git_create_pr" => self.git_create_pr(&req.params), + "prs_list" => self.prs_list(), + "git_worktree_create" => self.git_worktree_create(&req.params), + "git_worktree_list" => self.git_worktree_list(), + _ => Err(McpError { + code: -32601, + message: format!("method '{}' not found", req.method), + }), + }; + + match result { + Ok(result) => McpResponse { + jsonrpc: "2.0".to_string(), + id: req.id.clone(), + result: Some(result), + error: None, + }, + Err(error) => McpResponse { + jsonrpc: "2.0".to_string(), + id: req.id.clone(), + result: None, + error: Some(error), + }, + } + } + + pub async fn execute_mcp_for_tauri(&self, req: &McpRequest) -> McpResponse { + self.execute_mcp(req).await + } + + fn tools_list(&self) -> Result { + Ok(serde_json::json!([ + { + "name": "git_status", + "description": "Show git repository status", + "inputSchema": {} + }, + { + "name": "git_commit", + "description": "Create commit from current index", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"] + } + }, + { + "name": "git_create_pr", + "description": "Create pull request metadata record", + "inputSchema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "from": {"type": "string"}, + "to": {"type": "string"} + }, + "required": ["title", "from", "to"] + } + }, + { + "name": "git_worktree_create", + "description": "Create git worktree and register in sqlite", + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "path": {"type": "string"}, + "branch": {"type": "string"} + }, + "required": ["name", "path", "branch"] + } + } + ])) + } + + fn open_repo(&self) -> Result { + git2::Repository::open(self.repo_path.as_str()).map_err(|_| McpError { + code: -32000, + message: "repository not found".to_string(), + }) + } + + fn git_status(&self) -> Result { + let repo = self.open_repo()?; + let mut status_opts = git2::StatusOptions::new(); + status_opts.include_untracked(true).recurse_untracked_dirs(true); + + let statuses = repo + .statuses(Some(&mut status_opts)) + .map_err(|e| McpError { + code: -32001, + message: e.to_string(), + })?; + + let files: Vec<_> = statuses + .iter() + .map(|entry| { + serde_json::json!({ + "path": entry.path().unwrap_or(""), + "status": format!("{:?}", entry.status()) + }) + }) + .collect(); + + Ok(serde_json::json!({ + "success": true, + "count": files.len(), + "files": files + })) + } + + fn git_commit(&self, params: &serde_json::Value) -> Result { + let message = params + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("MCP commit") + .to_string(); + + let repo = self.open_repo()?; + let mut index = repo.index().map_err(|e| McpError { + code: -32002, + message: format!("failed to open index: {e}"), + })?; + + index.write().map_err(|e| McpError { + code: -32003, + message: format!("failed to write index: {e}"), + })?; + + let tree_id = index.write_tree().map_err(|e| McpError { + code: -32004, + message: format!("failed to write tree: {e}"), + })?; + + let tree = repo.find_tree(tree_id).map_err(|e| McpError { + code: -32005, + message: format!("failed to find tree: {e}"), + })?; + + let signature = repo + .signature() + .or_else(|_| git2::Signature::now("GitForge MCP", "mcp@gitforge.dev")) + .map_err(|e| McpError { + code: -32006, + message: format!("failed to create signature: {e}"), + })?; + + let parent_commit = repo + .head() + .ok() + .and_then(|h| h.target()) + .and_then(|oid| repo.find_commit(oid).ok()); + + let commit_id = if let Some(parent) = parent_commit.as_ref() { + repo.commit(Some("HEAD"), &signature, &signature, &message, &tree, &[parent]) + } else { + repo.commit(Some("HEAD"), &signature, &signature, &message, &tree, &[]) + } + .map_err(|e| McpError { + code: -32007, + message: format!("failed to commit: {e}"), + })?; + + Ok(serde_json::json!({ + "success": true, + "message": message, + "commit": commit_id.to_string() + })) + } + + fn git_create_pr(&self, params: &serde_json::Value) -> Result { + let title = params + .get("title") + .and_then(|v| v.as_str()) + .ok_or(McpError { + code: -32602, + message: "missing 'title'".to_string(), + })?; + + let from = params + .get("from") + .and_then(|v| v.as_str()) + .unwrap_or("feature"); + let to = params + .get("to") + .and_then(|v| v.as_str()) + .unwrap_or("main"); + + let db = self.db.lock().map_err(|_| McpError { + code: -32010, + message: "db lock poisoned".to_string(), + })?; + + db.execute( + "INSERT INTO prs (title, from_branch, to_branch) VALUES (?1, ?2, ?3)", + rusqlite::params![title, from, to], + ) + .map_err(|e| McpError { + code: -32011, + message: format!("failed to save PR: {e}"), + })?; + + Ok(serde_json::json!({ + "success": true, + "title": title, + "from": from, + "to": to, + "id": db.last_insert_rowid() + })) + } + + fn prs_list(&self) -> Result { + let db = self.db.lock().map_err(|_| McpError { + code: -32010, + message: "db lock poisoned".to_string(), + })?; + + let mut stmt = db + .prepare( + "SELECT id, title, from_branch, to_branch, state, created_at FROM prs ORDER BY id DESC", + ) + .map_err(|e| McpError { + code: -32012, + message: format!("failed to prepare query: {e}"), + })?; + + let rows = stmt + .query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "title": row.get::<_, String>(1)?, + "from": row.get::<_, String>(2)?, + "to": row.get::<_, String>(3)?, + "state": row.get::<_, String>(4)?, + "created_at": row.get::<_, String>(5)? + })) + }) + .map_err(|e| McpError { + code: -32013, + message: format!("failed to list PRs: {e}"), + })?; + + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| McpError { + code: -32014, + message: format!("failed to parse PR row: {e}"), + })?); + } + + Ok(serde_json::json!({ "items": items })) + } + + fn git_worktree_create(&self, params: &serde_json::Value) -> Result { + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or(McpError { + code: -32602, + message: "missing 'name'".to_string(), + })?; + let path = params + .get("path") + .and_then(|v| v.as_str()) + .ok_or(McpError { + code: -32602, + message: "missing 'path'".to_string(), + })?; + let branch = params + .get("branch") + .and_then(|v| v.as_str()) + .ok_or(McpError { + code: -32602, + message: "missing 'branch'".to_string(), + })?; + + let repo = self.open_repo()?; + if !Path::new(path).exists() { + std::fs::create_dir_all(path).map_err(|e| McpError { + code: -32015, + message: format!("failed to create worktree path: {e}"), + })?; + } + + let mut refname = format!("refs/heads/{branch}"); + if repo.find_reference(&refname).is_err() { + let head_commit = repo + .head() + .ok() + .and_then(|h| h.target()) + .and_then(|oid| repo.find_commit(oid).ok()) + .ok_or(McpError { + code: -32016, + message: "unable to derive HEAD commit for new branch".to_string(), + })?; + + repo.branch(branch, &head_commit, false).map_err(|e| McpError { + code: -32017, + message: format!("failed to create branch: {e}"), + })?; + refname = format!("refs/heads/{branch}"); + } + + repo.worktree(name, Path::new(path), None) + .map_err(|e| McpError { + code: -32018, + message: format!("failed to create worktree: {e}"), + })?; + + let db = self.db.lock().map_err(|_| McpError { + code: -32010, + message: "db lock poisoned".to_string(), + })?; + + db.execute( + "INSERT OR REPLACE INTO worktrees (name, path, branch) VALUES (?1, ?2, ?3)", + rusqlite::params![name, path, branch], + ) + .map_err(|e| McpError { + code: -32019, + message: format!("failed to register worktree: {e}"), + })?; + + Ok(serde_json::json!({ + "success": true, + "name": name, + "path": path, + "branch": branch, + "ref": refname + })) + } + + fn git_worktree_list(&self) -> Result { + let db = self.db.lock().map_err(|_| McpError { + code: -32010, + message: "db lock poisoned".to_string(), + })?; + + let mut stmt = db + .prepare("SELECT name, path, branch, created_at FROM worktrees ORDER BY id DESC") + .map_err(|e| McpError { + code: -32020, + message: format!("failed to prepare query: {e}"), + })?; + + let rows = stmt + .query_map([], |row| { + Ok(serde_json::json!({ + "name": row.get::<_, String>(0)?, + "path": row.get::<_, String>(1)?, + "branch": row.get::<_, String>(2)?, + "created_at": row.get::<_, String>(3)? + })) + }) + .map_err(|e| McpError { + code: -32021, + message: format!("failed to list worktrees: {e}"), + })?; + + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| McpError { + code: -32022, + message: format!("failed to parse worktree row: {e}"), + })?); + } + + Ok(serde_json::json!({ "items": items })) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(label: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock before unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("gitforge-{label}-{nanos}")); + dir.to_string_lossy().to_string() + } + + fn init_repo_with_file(repo_dir: &str) { + fs::create_dir_all(repo_dir).expect("create repo dir"); + let repo = git2::Repository::init(repo_dir).expect("init repo"); + let file_path = Path::new(repo_dir).join("README.md"); + fs::write(&file_path, "hello gitforge +").expect("write file"); + + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("README.md")).expect("stage readme"); + index.write().expect("write index"); + } + + #[tokio::test] + async fn mcp_tools_list_returns_expected_entries() { + let repo_dir = temp_path("tools-list"); + init_repo_with_file(&repo_dir); + + let server = GitForgeMcp::new(repo_dir.clone()).expect("create mcp server"); + let req = McpRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(1), + method: "tools/list".into(), + params: serde_json::json!({}), + }; + + let resp = server.execute_mcp_for_tauri(&req).await; + assert!(resp.error.is_none()); + let tools = resp.result.expect("tools result"); + assert!(tools.is_array()); + assert!(tools + .as_array() + .expect("tools array") + .iter() + .any(|tool| tool.get("name") == Some(&serde_json::json!("git_status")))); + } + + #[tokio::test] + async fn mcp_git_create_pr_and_list_roundtrip() { + let repo_dir = temp_path("pr-roundtrip"); + init_repo_with_file(&repo_dir); + + let server = GitForgeMcp::new(repo_dir.clone()).expect("create mcp server"); + + let create = McpRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(2), + method: "git_create_pr".into(), + params: serde_json::json!({ + "title": "Test PR", + "from": "feature/test", + "to": "main" + }), + }; + + let create_resp = server.execute_mcp_for_tauri(&create).await; + assert!(create_resp.error.is_none(), "{:?}", create_resp.error.map(|e| e.message)); + + let list = McpRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(3), + method: "prs_list".into(), + params: serde_json::json!({}), + }; + + let list_resp = server.execute_mcp_for_tauri(&list).await; + assert!(list_resp.error.is_none()); + let items = list_resp + .result + .expect("list result") + .get("items") + .expect("items key") + .as_array() + .expect("items array") + .clone(); + + assert!(!items.is_empty()); + assert_eq!(items[0].get("title"), Some(&serde_json::json!("Test PR"))); + } + + #[tokio::test] + async fn mcp_git_worktree_create_and_list_roundtrip() { + let repo_dir = temp_path("worktree-roundtrip"); + init_repo_with_file(&repo_dir); + + let server = GitForgeMcp::new(repo_dir.clone()).expect("create mcp server"); + + let wt_path = Path::new(&repo_dir).join(".worktrees").join("feature-x"); + let req = McpRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(4), + method: "git_worktree_create".into(), + params: serde_json::json!({ + "name": "feature-x", + "path": wt_path.to_string_lossy(), + "branch": "feature/x" + }), + }; + + let create_resp = server.execute_mcp_for_tauri(&req).await; + assert!(create_resp.error.is_none(), "{:?}", create_resp.error.map(|e| e.message)); + + let list_req = McpRequest { + jsonrpc: "2.0".into(), + id: serde_json::json!(5), + method: "git_worktree_list".into(), + params: serde_json::json!({}), + }; + + let list_resp = server.execute_mcp_for_tauri(&list_req).await; + assert!(list_resp.error.is_none()); + let items = list_resp + .result + .expect("worktree list result") + .get("items") + .expect("items key") + .as_array() + .expect("items array"); + + assert!(items.iter().any(|i| i.get("name") == Some(&serde_json::json!("feature-x")))); + } +} diff --git a/gitforge/src/bin/gitforge.rs b/gitforge/src/bin/gitforge.rs new file mode 100644 index 0000000..589bfd8 --- /dev/null +++ b/gitforge/src/bin/gitforge.rs @@ -0,0 +1,81 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "gitforge", about = "🔨 Forge your Git workflow")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// 🎨 Launch the desktop UI (Monaco + 5 columns) + Ui, + + /// 🤖 MCP server for Claude/Cursor/GPT + #[command(name = "mcp-serve")] + McpServe { + /// Repository path + #[arg(default_value = ".")] + repo: String, + }, + + /// 🧠 Local BPGT agent + Agent { + #[arg(default_value = ".")] + repo: String, + }, + + /// 🌳 Git worktree helper CLI + Worktree { + #[arg(value_enum)] + action: WorktreeAction, + name: Option, + }, + + /// 📱 Embedded browser + Browser { url: String }, +} + +#[derive(clap::ValueEnum, Clone)] +enum WorktreeAction { + Create, + List, + Switch, +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Some(Commands::Ui) => { + println!("🚀 GitForge UI + MCP + Voice starting..."); + } + Some(Commands::McpServe { repo }) => { + println!("🤖 MCP Server: ws://localhost:6767 for {}", repo); + } + Some(Commands::Agent { repo }) => { + println!("🧠 BPGT Agent + redb starting for {}", repo); + } + Some(Commands::Worktree { action, name }) => match action { + WorktreeAction::Create => { + println!( + "🌳 Worktree '{}' created", + name.unwrap_or_else(|| "new".to_string()) + ) + } + WorktreeAction::List => println!("📋 Worktree list"), + WorktreeAction::Switch => println!( + "🔀 Switched to worktree '{}'", + name.unwrap_or_else(|| "default".to_string()) + ), + }, + Some(Commands::Browser { url }) => { + println!("🌐 Opening {} in GitForge Browser", url); + } + None => { + println!("🔨 GitForge v2.0 — Forge your Git workflow"); + println!("Usage: gitforge ui | mcp-serve | agent | worktree"); + } + } +} diff --git a/gitforge/src/components/GitLayout.vue b/gitforge/src/components/GitLayout.vue new file mode 100644 index 0000000..4adba4b --- /dev/null +++ b/gitforge/src/components/GitLayout.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/gitforge/src/components/TerminalPanel.vue b/gitforge/src/components/TerminalPanel.vue new file mode 100644 index 0000000..23242b2 --- /dev/null +++ b/gitforge/src/components/TerminalPanel.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/gitforge/src/components/ThemePanel.vue b/gitforge/src/components/ThemePanel.vue new file mode 100644 index 0000000..3abc574 --- /dev/null +++ b/gitforge/src/components/ThemePanel.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/gitforge/src/style/neumorphism.css b/gitforge/src/style/neumorphism.css new file mode 100644 index 0000000..eeaca5a --- /dev/null +++ b/gitforge/src/style/neumorphism.css @@ -0,0 +1,4 @@ +.neumorphism-card { + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.1); +} diff --git a/gitforge/src/style/themes.css b/gitforge/src/style/themes.css new file mode 100644 index 0000000..c3e1430 --- /dev/null +++ b/gitforge/src/style/themes.css @@ -0,0 +1,7 @@ +:root { + --theme-professional-bg: #16161e; + --theme-light-bg: #f8fafc; + --theme-warm-bg: #2b1f1a; + --theme-cool-bg: #0f172a; + --theme-minimal-bg: #111827; +} diff --git a/gitforge/tauri.conf.json b/gitforge/tauri.conf.json new file mode 100644 index 0000000..ea6d1a6 --- /dev/null +++ b/gitforge/tauri.conf.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "GitForge", + "version": "2.0.0", + "identifier": "dev.gitforge.app", + "build": { + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "GitForge", + "width": 1600, + "height": 900 + } + ] + } +}