diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d08c8d7894..01428f4963 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,67 +1 @@ -## General information - -This is a monorepo with multiple projects. -The main applications are found in the `apps` directory. -They are: - -- `desktop` containing the Tauri application's frontend code -- `web` containing the web application's frontend code - -The backend of the Tauri application is found in the `crates` directory. -It contains different rust packages, all used for the Tauri application. - -The `packages` directory contains different self-contained npm packages. -These are shared between the `desktop` and `web` applications. -The packages are: - -- `ui` containing the shared UI components -- `shared` containing the shared types and utils -- `mcp` containing the Model Context Protocol packages -- `no-relative-imports` containing the no-relative-imports ESLINT package - -## Version control - -- Use GitButler tools -- The MCP tools require the absolute path to the repository -- Don't use any other git commands - -### Absorb - -When told to 'absorb' follow these steps: - -1. If there were any instructions given, take them into account. -2. List the file changes -3. Get the hunk dependencies -4. For all files that depend on a **single** commit, amend the file onto that commit. Every time a file is amended, list the hunk dependencies again. -5. If there are no dependencies, list the stacks. Based on the stack branch names and descriptions, determine the best branch to commit the changes to. -6. List the commits in the branch determined in the previous step, and then determine the best commit to amend the changes to based on the description. Update the description if needed. - -### Figure out the commits - -When told to 'figure out the commits' follow these steps: - -1. List the file changes -2. List the stacks -3. Figure out, based on the changes and whether there are any applied stacks/branches: -4. Create a plan for the commits. For that, take a look at the `Create a commit plan` section below. -5. Always execute the plan, and commit the changes as previously determined unless otherwise directed. - -### Creating a commit plan - -Follow this instructions when creating a commit plan: - -1. Group the file changes into logical groups - - Take a look at their diffs and determine if they are related. - - Groups are good, but prefer to have smaller commits than larger ones. - - Granularity is good. -2. Determine if any branches should be created - - If there are any stacks with branches, take a look at the branch names and descriptions and match them with the file changes. - - Create multiple branches if needed. You can tell if multiple branches are needed if the file changes span multiple projects in the monorepo. -3. Determine the commits - - For each group of file changes, determine the commit message. Be descriptive and explain what the changes are. - - If branches need to be created, use a descriptive name for the branch. - - Determine the order of the commits. If there are any dependencies between the commits, make sure to commit them in the correct order. - - Define which commits should go into which branches. - - If there were any other instructions given, take them into account. - -In the end, the plan should contain a list of the branches to be created (if any) and the commits to be made +After every session that adds, deletes or modifies code, call the gitbutler update_branch MCP call to update the current branch and then inform the user what happened based on the output of that call. diff --git a/Cargo.lock b/Cargo.lock index 437c1f154b..99893de1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2698,6 +2698,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "gitbutler-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "but-settings", + "clap", + "gitbutler-branch", + "gitbutler-branch-actions", + "gitbutler-command-context", + "gitbutler-diff", + "gitbutler-oplog", + "gitbutler-oxidize", + "gitbutler-project", + "gitbutler-reference", + "gitbutler-stack", + "gix", + "rmcp", + "serde", + "serde_json", + "temp-dir", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "gitbutler-notify-debouncer" version = "0.0.0" @@ -6060,6 +6088,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -6963,6 +6997,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64 0.21.7", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "rstest" version = "0.23.0" @@ -8376,6 +8442,12 @@ dependencies = [ "toml", ] +[[package]] +name = "temp-dir" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" + [[package]] name = "tempfile" version = "3.19.1" diff --git a/crates/gitbutler-mcp/Cargo.toml b/crates/gitbutler-mcp/Cargo.toml new file mode 100644 index 0000000000..afc4b3e185 --- /dev/null +++ b/crates/gitbutler-mcp/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "gitbutler-mcp" +version = "0.1.0" +edition = "2021" +description = "Model Context Protocol server for GitButler" +authors = ["GitButler "] +publish = false + +[[bin]] +name = "gitbutler-mcp" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = [ + "macros", + "rt", + "rt-multi-thread", + "io-std", + "signal", +] } +anyhow = "1.0" +async-trait = "0.1" +clap = { version = "4.4", features = ["derive"] } +rmcp = { version = "0.1.5", features = ["server", "transport-io"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "std", + "fmt", +] } +gitbutler-oplog.workspace = true +gitbutler-project.workspace = true +gitbutler-reference.workspace = true +gitbutler-branch-actions.workspace = true +gitbutler-command-context.workspace = true +gitbutler-branch.workspace = true +gitbutler-diff.workspace = true +but-settings.workspace = true +gitbutler-stack.workspace = true +gitbutler-oxidize.workspace = true +gix = { workspace = true, features = ["max-performance", "tracing"] } + +[dev-dependencies] +temp-dir = "0.1" diff --git a/crates/gitbutler-mcp/README.md b/crates/gitbutler-mcp/README.md new file mode 100644 index 0000000000..65b0d98634 --- /dev/null +++ b/crates/gitbutler-mcp/README.md @@ -0,0 +1,23 @@ +# Butler MCP + +This crate implments a single binary Rust MCP server that can be pointed to by an Agent to do some basic branch management work with the GitButler tooling. + +It implements a single tool called 'update_branch' that will look at uncommitted changes in the working directory and either commit them or amend an existing commit. + +If there is no existing branch, it will create a new one. + +If AI capabilities are enabled, it will also use the AI to generate a commit message for the changes based on the prompt. + +## The Idea + +The concept is not to give an Agent an endpoint to every API that we have, which mainly results in being able to use the agent as a slow command line. The idea is to have a few very powerful tools that can be used to do a lot of work automatically. + +Most of the work should be done in GitButler for more specific tasks, but updating a branch with new work generated via agentic work can be simple and powerful. + +## TODO + +- [ ] create a new branch if there is no existing one +- [ ] determine the actual branch name to use of everything existing +- [ ] determine if a new virtual branch should be created +- [ ] determine if work should be committed or amended +- [ ] use the AI to generate a commit message \ No newline at end of file diff --git a/crates/gitbutler-mcp/src/common/butler.rs b/crates/gitbutler-mcp/src/common/butler.rs new file mode 100644 index 0000000000..b901ced5c8 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/butler.rs @@ -0,0 +1,167 @@ +use rmcp::{ + const_string, model::*, schemars, service::RequestContext, tool, Error as McpError, RoleServer, + ServerHandler, +}; +use serde_json::json; +use std::path::PathBuf; +use tracing; + +use crate::common::commit::commit; +use crate::common::prepare::project_from_path; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UpdateBranchRequest { + pub working_directory: String, + pub full_prompt: String, + pub summary: String, +} + +#[derive(Clone)] +pub struct Butler {} + +#[tool(tool_box)] +impl Butler { + #[allow(dead_code)] + pub fn new() -> Self { + Self {} + } + + fn _create_resource_text(&self, uri: &str, name: &str) -> Resource { + RawResource::new(uri, name.to_string()).no_annotation() + } + + #[tool(description = "Update a branch with the given prompt and summary")] + fn update_branch( + &self, + #[tool(aggr)] UpdateBranchRequest { + working_directory, + full_prompt, + summary, + }: UpdateBranchRequest, + ) -> Result { + tracing::info!("Updating branch with prompt: {}", summary); + + // Check if the working directory exists + let project_path = PathBuf::from(&working_directory); + if !project_path.exists() { + return Err(McpError::invalid_params( + "Invalid working directory", + Some(json!({ "error": "Working directory does not exist" })), + )); + } + + let project = project_from_path(project_path).unwrap(); + dbg!(&project); + + let _commit = commit(project, full_prompt.clone(), summary.clone()); + dbg!(&_commit); + + // In a real implementation, we would use GitButler's branch management APIs + // But for now, we'll simulate a successful branch update + tracing::info!( + "Would update branch in {} using prompt: {} with summary: {}", + working_directory, + full_prompt, + summary + ); + + Ok(CallToolResult::success(vec![Content::text(format!( + "Branch has been updated with summary: {}", + summary + ))])) + } +} + +// simple test with my working directory +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_branch() { + let butler = Butler::new(); + let request = UpdateBranchRequest { + working_directory: "/Users/schacon/projects/gitbutler".to_string(), + full_prompt: "Update branch with new changes".to_string(), + summary: "Updated branch with new changes".to_string(), + }; + + let result = butler.update_branch(request); + dbg!(result); + } +} + +const_string!(UpdateBranch = "updateBranch"); + +#[tool(tool_box)] +impl ServerHandler for Butler { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder() + .enable_prompts() + .enable_resources() + .enable_tools() + .build(), + server_info: Implementation::from_build_env(), + instructions: Some("This server provides a branch update tool that can process prompts and update branches accordingly.".to_string()), + } + } + + async fn list_resources( + &self, + _request: std::option::Option, + _: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![], + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _: RequestContext, + ) -> Result { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ + "uri": uri + })), + )) + } + + async fn list_prompts( + &self, + _request: std::option::Option, + _: RequestContext, + ) -> Result { + Ok(ListPromptsResult { + next_cursor: None, + prompts: vec![], + }) + } + + async fn get_prompt( + &self, + GetPromptRequestParam { + name: _name, + arguments: _arguments, + }: GetPromptRequestParam, + _: RequestContext, + ) -> Result { + Err(McpError::invalid_params("prompt not found", None)) + } + + async fn list_resource_templates( + &self, + _request: std::option::Option, + _: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + next_cursor: None, + resource_templates: Vec::new(), + }) + } +} diff --git a/crates/gitbutler-mcp/src/common/commit.rs b/crates/gitbutler-mcp/src/common/commit.rs new file mode 100644 index 0000000000..34dbfee7c6 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/commit.rs @@ -0,0 +1,76 @@ +use anyhow::{bail, Result}; +use but_settings::AppSettings; +use gitbutler_branch::{BranchCreateRequest, BranchIdentity, BranchUpdateRequest}; +use gitbutler_branch_actions::{get_branch_listing_details, list_branches, BranchManagerExt}; +use gitbutler_command_context::CommandContext; +use gitbutler_project::Project; +use gitbutler_reference::{LocalRefname, Refname}; +use gitbutler_stack::{Stack, VirtualBranchesHandle}; + +pub fn commit(project: Project, full_prompt: String, summary: String) -> Result<()> { + let ctx = CommandContext::open(&project, AppSettings::default())?; + let list_result = gitbutler_branch_actions::list_virtual_branches(&ctx)?; + + // just get the first stack for now + let stack = VirtualBranchesHandle::new(project.gb_dir()) + .list_stacks_in_workspace()? + .into_iter() + .next() + .ok_or(anyhow::anyhow!("No stacks found in the project directory"))?; + + dbg!(&stack); + + if !list_result.skipped_files.is_empty() { + eprintln!( + "{} files could not be processed (binary or large size)", + list_result.skipped_files.len() + ) + } + + dbg!(&list_result); + + let target_branch = list_result + .branches + .iter() + .next() + .expect("A populated branch exists for a branch we can list"); + if target_branch.ownership.claims.is_empty() { + bail!( + "Branch has no change to commit{hint}", + hint = { + let candidate_names = list_result + .branches + .iter() + .filter_map(|b| (!b.ownership.claims.is_empty()).then_some(b.name.as_str())) + .collect::>(); + let mut candidates = candidate_names.join(", "); + if !candidate_names.is_empty() { + candidates = format!( + ". {candidates} {have} changes.", + have = if candidate_names.len() == 1 { + "has" + } else { + "have" + } + ) + }; + candidates + } + ) + } + + let message = full_prompt + "\n\n" + &summary; + dbg!(&message); + + let _oid = gitbutler_branch_actions::create_commit( + &ctx, + stack.id, + &message, + Some(&target_branch.ownership), + )?; + + dbg!("Commit created successfully"); + dbg!(_oid); + + Ok(()) +} diff --git a/crates/gitbutler-mcp/src/common/mod.rs b/crates/gitbutler-mcp/src/common/mod.rs new file mode 100644 index 0000000000..f0256369b8 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/mod.rs @@ -0,0 +1,3 @@ +pub mod butler; +pub mod commit; +pub mod prepare; diff --git a/crates/gitbutler-mcp/src/common/prepare.rs b/crates/gitbutler-mcp/src/common/prepare.rs new file mode 100644 index 0000000000..73d8279684 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/prepare.rs @@ -0,0 +1,8 @@ +use std::path::PathBuf; + +use anyhow::{bail, Context}; +use gitbutler_project::Project; + +pub fn project_from_path(path: PathBuf) -> anyhow::Result { + Project::from_path(&path) +} diff --git a/crates/gitbutler-mcp/src/main.rs b/crates/gitbutler-mcp/src/main.rs new file mode 100644 index 0000000000..7f41200c08 --- /dev/null +++ b/crates/gitbutler-mcp/src/main.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use common::butler::Butler; + +use rmcp::{transport::stdio, ServiceExt}; +use tracing_subscriber::{self, EnvFilter}; +mod common; +/// npx @modelcontextprotocol/inspector cargo run -p mcp-server-examples --example std_io +#[tokio::main] +async fn main() -> Result<()> { + // Initialize the tracing subscriber with file and stdout logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into())) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + tracing::info!("Starting GitButler MCP server"); + + // Create an instance of our counter router + let service = Butler::new().serve(stdio()).await.inspect_err(|e| { + tracing::error!("serving error: {:?}", e); + })?; + + service.waiting().await?; + Ok(()) +}