diff --git a/AGENTS.md b/AGENTS.md index f08f3388..1dfc7462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,7 +294,34 @@ Edit files in `inst/prompts/`. These are assembled in `btw_client()` based on co - **Tool functions return `tool_result()` objects** with value and optional display metadata - **All tool wrappers accept `_intent` parameter** (added automatically by `wrap_with_intent()`) - **Snapshot tests for tool outputs** to catch formatting changes -- **Use `check_*()` functions** from rlang for argument validation +- **Use `check_*()` functions** argument validation, described below + +### Type Check Functions + +#### Scalars +- `check_bool()` - Validates single TRUE/FALSE value +- `check_string()` - Validates single string (allows empty by default) +- `check_name()` - Validates non-empty string for use as name +- `check_number_decimal()` - Validates single numeric value (allows decimals) +- `check_number_whole()` - Validates single integer value +- `check_symbol()` - Validates R symbol/name object +- `check_arg()` - Validates argument name (symbol) +- `check_call()` - Validates defused call object +- `check_environment()` - Validates environment object +- `check_function()` - Validates function (any type) +- `check_closure()` - Validates R function (not primitive/builtin) +- `check_formula()` - Validates formula object + +#### Vectors +- `check_character()` - Validates character vector +- `check_logical()` - Validates logical vector +- `check_data_frame()` - Validates data frame + +#### Common Parameters +- All functions support `allow_null` parameter +- Most scalars support `allow_na` +- Number checkers support `min`/`max` bounds and `allow_infinite` +- All use `arg`/`call` for error context ## Important Notes diff --git a/DESCRIPTION b/DESCRIPTION index 32ecf853..1eb393d2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -82,6 +82,7 @@ Collate: 'tool-environment.R' 'tool-files-code-search.R' 'tool-files.R' + 'tool-git.R' 'tool-rstudioapi.R' 'tool-search-packages.R' 'tool-session-package-installed.R' diff --git a/NAMESPACE b/NAMESPACE index 9bf5001c..44437cc7 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -38,6 +38,13 @@ export(btw_tool_files_code_search) export(btw_tool_files_list_files) export(btw_tool_files_read_text_file) export(btw_tool_files_write_text_file) +export(btw_tool_git_branch_checkout) +export(btw_tool_git_branch_create) +export(btw_tool_git_branch_list) +export(btw_tool_git_commit) +export(btw_tool_git_diff) +export(btw_tool_git_log) +export(btw_tool_git_status) export(btw_tool_ide_read_current_editor) export(btw_tool_search_package_info) export(btw_tool_search_packages) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index db77a993..384bb4ff 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -164,6 +164,29 @@ btw_app_from_client <- function(client, messages = list(), ...) { } }) + lapply(tool_groups, function(group) { + shiny::observeEvent(input[[paste0("tools_toggle_", group)]], { + current <- input[[paste0("tools_", group)]] + all_tools <- btw_tools_df() + group_tools <- all_tools[all_tools$group == group, ][["name"]] + if (length(current) == length(group_tools)) { + # All selected, so deselect all + shiny::updateCheckboxGroupInput( + session = session, + inputId = paste0("tools_", group), + selected = "" + ) + } else { + # Not all selected, so select all + shiny::updateCheckboxGroupInput( + session = session, + inputId = paste0("tools_", group), + selected = group_tools + ) + } + }) + }) + shiny::observe({ if (!length(selected_tools())) { client$set_tools(list()) @@ -256,34 +279,33 @@ app_tool_group_choice_input <- function( initial_tool_names <- group_tools_df$name } - label_icon <- switch( - group, - "docs" = tool_icon("dictionary"), - "env" = tool_icon("source-environment"), - "files" = tool_icon("folder-open"), - "ide" = tool_icon("code-blocks"), - "search" = tool_icon("search"), - "session" = tool_icon("screen-search-desktop"), - "web" = tool_icon("globe-book"), - tool_icon("construction") - ) + label_icon <- tool_group_icon(group, "construction") label_text <- switch( group, "docs" = shiny::span(label_icon, "Documentation"), "env" = shiny::span(label_icon, "Environment"), "files" = shiny::span(label_icon, "Files"), + "git" = shiny::span(label_icon, "Git"), "ide" = shiny::span(label_icon, "IDE"), "search" = shiny::span(label_icon, "Search"), "session" = shiny::span(label_icon, "Session Info"), "web" = shiny::span(label_icon, "Web Tools"), "other" = shiny::span(label_icon, "Other Tools"), - paste0(toupper(substring(group, 1, 1)), substring(group, 2)) + to_title_case(group) ) shiny::checkboxGroupInput( inputId = paste0("tools_", group), - label = shiny::h3(label_text, class = "h6 mb-0"), + label = shiny::h3( + class = "h6 mb-0", + shiny::actionLink( + paste0("tools_toggle_", group), + label_text, + class = "link-body-emphasis", + style = "text-decoration: none;" + ) + ), choiceNames = choice_names, choiceValues = group_tools_df$name, selected = intersect(group_tools_df$name, initial_tool_names), diff --git a/R/edit_btw_md.R b/R/edit_btw_md.R index 228c9789..7f18d233 100644 --- a/R/edit_btw_md.R +++ b/R/edit_btw_md.R @@ -110,7 +110,7 @@ #' ```` #' --- #' client: claude/claude-4-5-sonnet-latest -#' tools: [docs, env, files, ide, search, session, web] +#' tools: [docs, env, files, git, ide, search, session, web] #' --- #' #' Follow these important style rules when writing R code: diff --git a/R/tool-git.R b/R/tool-git.R new file mode 100644 index 00000000..5c5df267 --- /dev/null +++ b/R/tool-git.R @@ -0,0 +1,643 @@ +#' @include tool-result.R +NULL + +# Git Status ------------------------------------------------------------------ + +#' Tool: Git Status +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_status() +#' } +#' +#' @param include One of `"both"`, `"staged"`, or `"unstaged"`. Use `"both"` to +#' show both staged and unstaged files (default). +#' @param pathspec Optional character vector with paths to match. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a character table of file statuses. +#' +#' @family git tools +#' @export +btw_tool_git_status <- function(include, pathspec, `_intent`) {} + +btw_tool_git_status_impl <- function( + include = c("both", "staged", "unstaged"), + pathspec = NULL +) { + check_installed("gert") + include <- arg_match(include) + check_character(pathspec, allow_null = TRUE) + + staged <- switch( + include, + both = NULL, + staged = TRUE, + unstaged = FALSE + ) + + status <- gert::git_status(staged = staged, pathspec = pathspec) + + if (nrow(status) == 0) { + return(btw_tool_result("No changes to report")) + } + + md_res <- glue_( + "{{ status$file }} [{{ status$status }}]{{ ifelse(status$staged, ' +staged', ' -unstaged') }}" + ) + + btw_tool_result( + paste(md_res, collapse = "\n"), + data = status, + display = list(markdown = paste("*", md_res, collapse = "\n")) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_status", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_status_impl, + name = "btw_tool_git_status", + description = r"---(Show the status of the git working directory. + +WHEN TO USE: +* Use this tool to see which files have been modified, staged, or are untracked. +* This is typically the first tool to call when working with git operations. +* If a staged file also has unstaged modifications, only the staged file and status are listed. + +RETURNS: A list of file paths, their status (new, modified, deleted, etc.), and whether they are staged or unstaged. + )---", + annotations = ellmer::tool_annotations( + title = "Git Status", + read_only_hint = TRUE, + open_world_hint = FALSE, + idempotent_hint = FALSE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + include = ellmer::type_enum( + c("both", "staged", "unstaged"), + 'Optional. One of "both" (default), "staged", or "unstaged" to filter status output.', + required = FALSE + ), + pathspec = ellmer::type_array( + "Optional. Character vector with paths to match.", + items = ellmer::type_string(), + required = FALSE + ) + ) + ) + } +) + +# Git Diff -------------------------------------------------------------------- + +#' Tool: Git Diff +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_diff() +#' btw_tool_git_diff(ref = "HEAD") +#' } +#' +#' @inheritParams gert::git_diff_patch +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a diff patch as a formatted string. +#' +#' @family git tools +#' @export +btw_tool_git_diff <- function(ref, `_intent`) {} + +btw_tool_git_diff_impl <- function(ref = NULL) { + check_installed("gert") + check_string(ref, allow_null = TRUE) + + diff_patch <- gert::git_diff_patch(ref = ref) + + if (length(diff_patch) == 0 || all(!nzchar(diff_patch))) { + msg <- if (is.null(ref)) { + "No unstaged changes to show" + } else { + sprintf("No changes to show for ref: %s", ref) + } + return(btw_tool_result(msg)) + } + + # Format as a code block + value <- md_code_block("diff", diff_patch) + + btw_tool_result( + value, + display = list( + markdown = value, + title = HTML(sprintf( + "Git Diff%s", + if (!is.null(ref)) sprintf(" (%s)", ref) else "" + )) + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_diff", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_diff_impl, + name = "btw_tool_git_diff", + description = r"---(View changes in the working directory or a commit. + +WHEN TO USE: +* Use with no arguments to see unstaged changes (working directory vs. index). +* Use with `ref = "HEAD"` to see staged changes (index vs. HEAD). +* Use with `ref = "commit_sha"` to see the diff of that commit. + +RETURNS: A unified diff patch showing the changes for a single commit. + +LIMITATION: This tool does not support diffing between two arbitrary commits. + )---", + annotations = ellmer::tool_annotations( + title = "Git Diff", + read_only_hint = TRUE, + open_world_hint = FALSE, + idempotent_hint = TRUE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + ref = ellmer::type_string( + 'Optional reference like "HEAD" or a commit id. `null` (default) shows unstaged changes.', + required = FALSE + ) + ) + ) + } +) + +# Git Log --------------------------------------------------------------------- + +#' Tool: Git Log +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_log() +#' btw_tool_git_log(max = 20, ref = "main") +#' } +#' +#' @param ref Revision string with a branch/tag/commit value. Defaults to +#' `"HEAD"`. +#' @param max Maximum number of commits to retrieve. Defaults to 10. +#' @param after Optional date or timestamp: only include commits after this +#' date. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a character table of commit history. +#' +#' @family git tools +#' @export +btw_tool_git_log <- function(ref, max, after, `_intent`) {} + +btw_tool_git_log_impl <- function( + ref = "HEAD", + max = 10, + after = NULL +) { + check_installed("gert") + check_string(ref) + check_number_whole(max, min = 1) + check_string(after, allow_null = TRUE) + + log <- gert::git_log(ref = ref, max = max, after = after) + + if (nrow(log) == 0) { + return(btw_tool_result("No commits found")) + } + + # Select and format relevant columns + names(log)[names(log) == "files"] <- "n_files" + fields <- c("message", "author", "time", "n_files", "commit") + + # Truncate commit SHA to 7 characters for display + log_display <- log + log_display$commit <- substr(log_display$commit, 1, 7) + + # Truncate long messages + log_display$message <- vapply( + log_display$message, + function(msg) { + first_line <- strsplit(msg, "\n")[[1]][1] + if (nchar(first_line) > 60) { + paste0(substr(first_line, 1, 57), "...") + } else { + first_line + } + }, + character(1) + ) + + btw_tool_result( + md_kv_table(log_display[fields]), + data = log, + display = list( + markdown = md_table(log_display[rev(fields)]) + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_log", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_log_impl, + name = "btw_tool_git_log", + description = r"---(Show the commit history for a repository. + +WHEN TO USE: +* Use this tool to view recent commits and their messages. +* Useful for understanding the history of changes before making new commits. +* Can filter by branch, number of commits, or date range. + +RETURNS: A list of commits with SHA (short), author, timestamp, number of files, and message. + )---", + annotations = ellmer::tool_annotations( + title = "Git Log", + read_only_hint = TRUE, + open_world_hint = FALSE, + idempotent_hint = FALSE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + ref = ellmer::type_string( + 'Revision string (branch/tag/commit). Defaults to "HEAD".', + required = FALSE + ), + max = ellmer::type_number( + "Maximum number of commits to retrieve. Defaults to 10.", + required = FALSE + ), + after = ellmer::type_string( + "Optional date or timestamp: only include commits after this date.", + required = FALSE + ) + ) + ) + } +) + +# Git Commit ------------------------------------------------------------------ + +#' Tool: Git Commit +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_commit(message = "Fix bug in analysis", files = c("analysis.R")) +#' } +#' +#' @param message A commit message describing the changes. +#' @param files Optional character vector of file paths to stage and commit. +#' Use `"."` to stage all changed files. If `NULL`, commits currently staged files. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns the commit SHA. +#' +#' @family git tools +#' @export +btw_tool_git_commit <- function(message, files, `_intent`) {} + +btw_tool_git_commit_impl <- function( + message, + files = NULL +) { + check_installed("gert") + check_string(message) + check_character(files, allow_null = TRUE) + + # Stage files if provided + if (!is.null(files)) { + gert::git_add(files) + } + + # Create commit + commit_sha <- gert::git_commit(message = message) + + result <- sprintf( + "Created commit: %s\nMessage: %s", + substr(commit_sha, 1, 7), + message + ) + + btw_tool_result( + result, + data = list(sha = commit_sha, message = message), + display = list( + markdown = md_code_block("", result), + title = "Git Commit" + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_commit", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_commit_impl, + name = "btw_tool_git_commit", + description = r"---(Stage files and create a git commit. + +WHEN TO USE: +* Use this tool to commit changes. +* If `files` is provided, those files will be staged before committing. +* If `files` is NULL, only currently staged files will be committed. + +IMPORTANT: +* Always provide a clear, descriptive commit message. +* You can review changes with btw_tool_git_status and btw_tool_git_diff before committing. +* This modifies the repository state. + +RETURNS: The commit SHA and confirmation message. + )---", + annotations = ellmer::tool_annotations( + title = "Git Commit", + read_only_hint = FALSE, + open_world_hint = FALSE, + idempotent_hint = FALSE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + message = ellmer::type_string( + "A commit message describing the changes." + ), + files = ellmer::type_array( + 'Optional. Files to stage before committing. Use "." for all changed files, or NULL to commit already staged files.', + items = ellmer::type_string(), + required = FALSE + ) + ) + ) + } +) + +# Git Branch List ------------------------------------------------------------- + +#' Tool: Git Branch List +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_branch_list() +#' btw_tool_git_branch_list(local = FALSE) # remote branches +#' } +#' +#' @param include Once of `"local"` (default), `"remote"`, or `"all"` to filter +#' branches to local branches only, remote branches only, or all branches. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a character table of branches. +#' +#' @family git tools +#' @export +btw_tool_git_branch_list <- function(include, `_intent`) {} + +btw_tool_git_branch_list_impl <- function( + include = c("local", "remote", "all") +) { + check_installed("gert") + include <- arg_match(include) + + local <- switch(include, local = TRUE, remote = FALSE, all = NULL) + + branches <- gert::git_branch_list(local = local) + + if (nrow(branches) == 0) { + return(btw_tool_result("No branches found")) + } + + # Select relevant columns + fields <- c("name", "upstream", "updated") + + branches <- branches[order(branches$updated, decreasing = TRUE), ] + + branches_llm <- glue_( + "{{ branches$name }} [{{ branches$updated }}]{{ ifelse(!is.na(branches$upstream), paste(' ->', branches$upstream), '') }} " + ) + + btw_tool_result( + paste(branches_llm, collapse = "\n"), + data = branches, + display = list(markdown = md_table(branches[fields])) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_branch_list", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_branch_list_impl, + name = "btw_tool_git_branch_list", + description = r"---(List git branches in the repository. + +WHEN TO USE: +* Use this tool to see available branches before checking out or creating a new branch. +* Shows local branches by default, but can also show remote branches. + +RETURNS: A table of branch names, upstream tracking, and last update time. + )---", + annotations = ellmer::tool_annotations( + title = "Git Branches", + read_only_hint = TRUE, + open_world_hint = FALSE, + idempotent_hint = TRUE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + include = ellmer::type_enum( + c("local", "remote", "all"), + 'Optional. Filter branches to "local" (default), "remote", or "all".', + required = FALSE + ) + ) + ) + } +) + +# Git Branch Create ----------------------------------------------------------- + +#' Tool: Git Branch Create +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_branch_create(branch = "feature/new-analysis") +#' } +#' +#' @param branch Name of the new branch to create. +#' @param ref Optional reference point for the new branch. Defaults to `"HEAD"`. +#' @param checkout Whether to check out the new branch after creation. Defaults +#' to `TRUE`. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a confirmation message. +#' +#' @family git tools +#' @export +btw_tool_git_branch_create <- function(branch, ref, checkout, `_intent`) {} + +btw_tool_git_branch_create_impl <- function( + branch, + ref = "HEAD", + checkout = TRUE +) { + check_installed("gert") + check_string(branch) + check_string(ref) + check_bool(checkout) + + gert::git_branch_create(branch = branch, ref = ref, checkout = checkout) + + result <- sprintf( + "Created branch `%s` from `%s`%s.", + branch, + ref, + if (checkout) " and checked it out" else "" + ) + + btw_tool_result( + result, + display = list( + markdown = result, + title = HTML(sprintf( + "Git Create Branch %s", + branch + )) + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_branch_create", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_branch_create_impl, + name = "btw_tool_git_branch_create", + description = r"---(Create a new git branch. + +WHEN TO USE: +* Use this tool to create a new branch for feature development or bug fixes. +* By default, the new branch is checked out automatically. +* The branch is created from HEAD unless a different ref is specified. + +IMPORTANT: +* This modifies the repository state. +* Ensure you're on the correct branch before creating from HEAD. + +RETURNS: Confirmation message with branch name and ref. + )---", + annotations = ellmer::tool_annotations( + title = "Git Branch Create", + read_only_hint = FALSE, + open_world_hint = FALSE, + idempotent_hint = FALSE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + branch = ellmer::type_string( + "Name of the new branch to create." + ), + ref = ellmer::type_string( + 'Optional reference point (branch/tag/commit) for the new branch. Defaults to "HEAD".', + required = FALSE + ), + checkout = ellmer::type_boolean( + "Whether to check out the new branch after creation. Defaults to TRUE.", + required = FALSE + ) + ) + ) + } +) + +# Git Branch Checkout --------------------------------------------------------- + +#' Tool: Git Branch Checkout +#' +#' @examples +#' \dontrun{ +#' btw_tool_git_branch_checkout(branch = "main") +#' } +#' +#' @param branch Name of branch to check out. +#' @param force Whether to force checkout even with uncommitted changes. +#' Defaults to `FALSE`. +#' @inheritParams btw_tool_docs_package_news +#' +#' @return Returns a confirmation message. +#' +#' @family git tools +#' @export +btw_tool_git_branch_checkout <- function(branch, force, `_intent`) {} + +btw_tool_git_branch_checkout_impl <- function( + branch, + force = FALSE +) { + check_installed("gert") + check_string(branch) + check_bool(force) + + gert::git_branch_checkout(branch = branch, force = force) + + result <- sprintf("Checked out branch '%s'", branch) + + btw_tool_result( + result, + display = list( + markdown = md_code_block("", result), + title = HTML("Git Checkout") + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_git_branch_checkout", + group = "git", + tool = function() { + ellmer::tool( + btw_tool_git_branch_checkout_impl, + name = "btw_tool_git_branch_checkout", + description = r"---(Switch to a different git branch. + +WHEN TO USE: +* Use this tool to switch between existing branches. +* Check btw_tool_git_status first to ensure no uncommitted changes will be lost. +* Use btw_tool_git_branch_list to see available branches. + +IMPORTANT: +* This modifies the repository state and working directory. +* Will fail if there are uncommitted changes unless force is true. +* Using `force: true` can lose uncommitted changes. + +RETURNS: Confirmation message with branch name. + )---", + annotations = ellmer::tool_annotations( + title = "Git Checkout", + read_only_hint = FALSE, + open_world_hint = FALSE, + idempotent_hint = FALSE, + btw_can_register = function() rlang::is_installed("gert") + ), + arguments = list( + branch = ellmer::type_string( + "Name of the branch to check out." + ), + force = ellmer::type_boolean( + "Whether to force checkout even with uncommitted changes. Defaults to false. Use with caution.", + required = FALSE + ) + ) + ) + } +) diff --git a/R/tools.R b/R/tools.R index c069a687..2f817b87 100644 --- a/R/tools.R +++ b/R/tools.R @@ -132,24 +132,27 @@ wrap_with_intent <- function(tool) { tool } -set_tool_icon <- function(tool, group) { - icon <- switch( +tool_group_icon <- function(group, default = NULL) { + switch( group, "docs" = tool_icon("dictionary"), "env" = tool_icon("source-environment"), "files" = tool_icon("folder-open"), + "git" = tool_icon("difference"), "ide" = tool_icon("code-blocks"), "search" = tool_icon("search"), "session" = tool_icon("screen-search-desktop"), "web" = tool_icon("globe-book"), - NULL + if (!is.null(default)) tool_icon(default) ) +} +set_tool_icon <- function(tool, group) { if (!is.list(tool@annotations)) { tool@annotations <- list() } - tool@annotations$icon <- icon + tool@annotations$icon <- tool_group_icon(group) tool } @@ -188,6 +191,14 @@ tool_icon <- local({ }) x <- do.call(rbind, x) x <- x[order(x$Group, x$Name), ] - md_table(x) + x$Name <- sprintf("[%s()]", x$Name) + + res <- c() + for (group in unique(x$Group)) { + res <- c(res, paste0("### Group: ", group, "\n\n")) + res <- c(res, md_table(x[x$Group == group, c("Name", "Description")])) + res <- c(res, "\n\n") + } + paste(res, collapse = "\n") } # nocov end diff --git a/R/utils-md.R b/R/utils-md.R index 697aa9ec..d4718f6b 100644 --- a/R/utils-md.R +++ b/R/utils-md.R @@ -25,3 +25,24 @@ md_code_block <- function(type = "", ...) { } c(paste0(ticks, type), ..., ticks) } + +md_kv_table <- function(df, drop_na = FALSE) { + check_data_frame(df) + + res <- vector("character", nrow(df)) + + for (i in seq_len(nrow(df))) { + item <- vector("character", ncol(df)) + for (j in seq_len(ncol(df))) { + value <- df[[j]][[i]] + if (drop_na && is.na(value)) { + next + } + item[j] <- paste0(colnames(df)[j], ": ", format(value)) + } + item <- item[nzchar(item)] + res[i] <- paste(item, collapse = "\n") + } + + paste(res, collapse = "\n\n") +} diff --git a/R/utils.R b/R/utils.R index 9e17ff0b..2e7ab3ae 100644 --- a/R/utils.R +++ b/R/utils.R @@ -162,3 +162,7 @@ local_reproducible_output <- function( .local_envir = .env ) } + +to_title_case <- function(x) { + paste0(toupper(substring(x, 1, 1)), substring(x, 2)) +} diff --git a/inst/icons/difference.svg b/inst/icons/difference.svg new file mode 100644 index 00000000..ef17e661 --- /dev/null +++ b/inst/icons/difference.svg @@ -0,0 +1 @@ + diff --git a/inst/templates/btw.md b/inst/templates/btw.md index 21d76305..5b062268 100644 --- a/inst/templates/btw.md +++ b/inst/templates/btw.md @@ -1,6 +1,14 @@ --- client: claude/claude-4-5-sonnet-latest -tools: [docs, env, files, ide, search, session, web] +tools: + - docs + - env + - files + - git + - ide + - search + - session + - web --- Follow these important style rules when writing R code: diff --git a/man/btw_tool_git_branch_checkout.Rd b/man/btw_tool_git_branch_checkout.Rd new file mode 100644 index 00000000..5094c62a --- /dev/null +++ b/man/btw_tool_git_branch_checkout.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_branch_checkout} +\alias{btw_tool_git_branch_checkout} +\title{Tool: Git Branch Checkout} +\usage{ +btw_tool_git_branch_checkout(branch, force = FALSE, `_intent` = "") +} +\arguments{ +\item{branch}{Name of branch to check out.} + +\item{force}{Whether to force checkout even with uncommitted changes. +Defaults to \code{FALSE}.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a confirmation message. +} +\description{ +Tool: Git Branch Checkout +} +\examples{ +\dontrun{ +btw_tool_git_branch_checkout(branch = "main") +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_create}()}, +\code{\link{btw_tool_git_branch_list}()}, +\code{\link{btw_tool_git_commit}()}, +\code{\link{btw_tool_git_diff}()}, +\code{\link{btw_tool_git_log}()}, +\code{\link{btw_tool_git_status}()} +} +\concept{git tools} diff --git a/man/btw_tool_git_branch_create.Rd b/man/btw_tool_git_branch_create.Rd new file mode 100644 index 00000000..0f18db6b --- /dev/null +++ b/man/btw_tool_git_branch_create.Rd @@ -0,0 +1,47 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_branch_create} +\alias{btw_tool_git_branch_create} +\title{Tool: Git Branch Create} +\usage{ +btw_tool_git_branch_create( + branch, + ref = "HEAD", + checkout = TRUE, + `_intent` = "" +) +} +\arguments{ +\item{branch}{Name of the new branch to create.} + +\item{ref}{Optional reference point for the new branch. Defaults to \code{"HEAD"}.} + +\item{checkout}{Whether to check out the new branch after creation. Defaults +to \code{TRUE}.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a confirmation message. +} +\description{ +Tool: Git Branch Create +} +\examples{ +\dontrun{ +btw_tool_git_branch_create(branch = "feature/new-analysis") +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_checkout}()}, +\code{\link{btw_tool_git_branch_list}()}, +\code{\link{btw_tool_git_commit}()}, +\code{\link{btw_tool_git_diff}()}, +\code{\link{btw_tool_git_log}()}, +\code{\link{btw_tool_git_status}()} +} +\concept{git tools} diff --git a/man/btw_tool_git_branch_list.Rd b/man/btw_tool_git_branch_list.Rd new file mode 100644 index 00000000..6539d21b --- /dev/null +++ b/man/btw_tool_git_branch_list.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_branch_list} +\alias{btw_tool_git_branch_list} +\title{Tool: Git Branch List} +\usage{ +btw_tool_git_branch_list(include = c("local", "remote", "all"), `_intent` = "") +} +\arguments{ +\item{include}{Once of \code{"local"} (default), \code{"remote"}, or \code{"all"} to filter +branches to local branches only, remote branches only, or all branches.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a character table of branches. +} +\description{ +Tool: Git Branch List +} +\examples{ +\dontrun{ +btw_tool_git_branch_list() +btw_tool_git_branch_list(local = FALSE) # remote branches +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_checkout}()}, +\code{\link{btw_tool_git_branch_create}()}, +\code{\link{btw_tool_git_commit}()}, +\code{\link{btw_tool_git_diff}()}, +\code{\link{btw_tool_git_log}()}, +\code{\link{btw_tool_git_status}()} +} +\concept{git tools} diff --git a/man/btw_tool_git_commit.Rd b/man/btw_tool_git_commit.Rd new file mode 100644 index 00000000..55b1a214 --- /dev/null +++ b/man/btw_tool_git_commit.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_commit} +\alias{btw_tool_git_commit} +\title{Tool: Git Commit} +\usage{ +btw_tool_git_commit(message, files = NULL, `_intent` = "") +} +\arguments{ +\item{message}{A commit message describing the changes.} + +\item{files}{Optional character vector of file paths to stage and commit. +Use \code{"."} to stage all changed files. If \code{NULL}, commits currently staged files.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns the commit SHA. +} +\description{ +Tool: Git Commit +} +\examples{ +\dontrun{ +btw_tool_git_commit(message = "Fix bug in analysis", files = c("analysis.R")) +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_checkout}()}, +\code{\link{btw_tool_git_branch_create}()}, +\code{\link{btw_tool_git_branch_list}()}, +\code{\link{btw_tool_git_diff}()}, +\code{\link{btw_tool_git_log}()}, +\code{\link{btw_tool_git_status}()} +} +\concept{git tools} diff --git a/man/btw_tool_git_diff.Rd b/man/btw_tool_git_diff.Rd new file mode 100644 index 00000000..375405a9 --- /dev/null +++ b/man/btw_tool_git_diff.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_diff} +\alias{btw_tool_git_diff} +\title{Tool: Git Diff} +\usage{ +btw_tool_git_diff(ref = NULL, `_intent` = "") +} +\arguments{ +\item{ref}{a reference such as \code{"HEAD"}, or a commit id, or \code{NULL} +to the diff the working directory against the repository index.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a diff patch as a formatted string. +} +\description{ +Tool: Git Diff +} +\examples{ +\dontrun{ +btw_tool_git_diff() +btw_tool_git_diff(ref = "HEAD") +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_checkout}()}, +\code{\link{btw_tool_git_branch_create}()}, +\code{\link{btw_tool_git_branch_list}()}, +\code{\link{btw_tool_git_commit}()}, +\code{\link{btw_tool_git_log}()}, +\code{\link{btw_tool_git_status}()} +} +\concept{git tools} diff --git a/man/btw_tool_git_log.Rd b/man/btw_tool_git_log.Rd new file mode 100644 index 00000000..b4f53645 --- /dev/null +++ b/man/btw_tool_git_log.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_log} +\alias{btw_tool_git_log} +\title{Tool: Git Log} +\usage{ +btw_tool_git_log(ref = "HEAD", max = 10, after = NULL, `_intent` = "") +} +\arguments{ +\item{ref}{Revision string with a branch/tag/commit value. Defaults to +\code{"HEAD"}.} + +\item{max}{Maximum number of commits to retrieve. Defaults to 10.} + +\item{after}{Optional date or timestamp: only include commits after this +date.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a character table of commit history. +} +\description{ +Tool: Git Log +} +\examples{ +\dontrun{ +btw_tool_git_log() +btw_tool_git_log(max = 20, ref = "main") +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_checkout}()}, +\code{\link{btw_tool_git_branch_create}()}, +\code{\link{btw_tool_git_branch_list}()}, +\code{\link{btw_tool_git_commit}()}, +\code{\link{btw_tool_git_diff}()}, +\code{\link{btw_tool_git_status}()} +} +\concept{git tools} diff --git a/man/btw_tool_git_status.Rd b/man/btw_tool_git_status.Rd new file mode 100644 index 00000000..e7506362 --- /dev/null +++ b/man/btw_tool_git_status.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-git.R +\name{btw_tool_git_status} +\alias{btw_tool_git_status} +\title{Tool: Git Status} +\usage{ +btw_tool_git_status( + include = c("both", "staged", "unstaged"), + pathspec = NULL, + `_intent` = "" +) +} +\arguments{ +\item{include}{One of \code{"both"}, \code{"staged"}, or \code{"unstaged"}. Use \code{"both"} to +show both staged and unstaged files (default).} + +\item{pathspec}{Optional character vector with paths to match.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +Returns a character table of file statuses. +} +\description{ +Tool: Git Status +} +\examples{ +\dontrun{ +btw_tool_git_status() +} + +} +\seealso{ +Other git tools: +\code{\link{btw_tool_git_branch_checkout}()}, +\code{\link{btw_tool_git_branch_create}()}, +\code{\link{btw_tool_git_branch_list}()}, +\code{\link{btw_tool_git_commit}()}, +\code{\link{btw_tool_git_diff}()}, +\code{\link{btw_tool_git_log}()} +} +\concept{git tools} diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index be8046c6..bb420c04 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -29,26 +29,78 @@ place. The \code{chat} input is returned invisibly. The \code{btw_tools()} function provides a list of tools that can be registered with an ellmer chat via \code{chat$set_tools()} that allow the chat to interface with your computational environment. Chats returned by -this function have access to the tools:\tabular{lll}{ - Name \tab Group \tab Description \cr - btw_tool_docs_available_vignettes \tab docs \tab List available vignettes for an R package. \cr - btw_tool_docs_help_page \tab docs \tab Get help page from package. \cr - btw_tool_docs_package_help_topics \tab docs \tab Get available help topics for an R package. \cr - btw_tool_docs_package_news \tab docs \tab Read the release notes (NEWS) for a package. \cr - btw_tool_docs_vignette \tab docs \tab Get a package vignette in plain text. \cr - btw_tool_env_describe_data_frame \tab env \tab Show the data frame or table or get information about the structure of a data frame or table. \cr - btw_tool_env_describe_environment \tab env \tab List and describe items in an environment. \cr - btw_tool_files_code_search \tab files \tab Search code files in the project. \cr - btw_tool_files_list_files \tab files \tab List files or directories in the project. \cr - btw_tool_files_read_text_file \tab files \tab Read an entire text file. \cr - btw_tool_files_write_text_file \tab files \tab Write content to a text file. \cr - btw_tool_ide_read_current_editor \tab ide \tab Read the contents of the editor that is currently open in the user's IDE. \cr - btw_tool_search_package_info \tab search \tab Describe a CRAN package. \cr - btw_tool_search_packages \tab search \tab Search for an R package on CRAN. \cr - btw_tool_session_check_package_installed \tab session \tab Check if a package is installed in the current session. \cr - btw_tool_session_package_info \tab session \tab Verify that a specific package is installed, or find out which packages are in use in the current session. \cr - btw_tool_session_platform_info \tab session \tab Describes the R version, operating system, language and locale settings for the user's system. \cr - btw_tool_web_read_url \tab web \tab Read a web page and convert it to Markdown format. \cr +this function have access to the tools: +\subsection{Group: docs}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_docs_available_vignettes]{btw_tool_docs_available_vignettes()}} \tab List available vignettes for an R package. \cr + \code{\link[=btw_tool_docs_help_page]{btw_tool_docs_help_page()}} \tab Get help page from package. \cr + \code{\link[=btw_tool_docs_package_help_topics]{btw_tool_docs_package_help_topics()}} \tab Get available help topics for an R package. \cr + \code{\link[=btw_tool_docs_package_news]{btw_tool_docs_package_news()}} \tab Read the release notes (NEWS) for a package. \cr + \code{\link[=btw_tool_docs_vignette]{btw_tool_docs_vignette()}} \tab Get a package vignette in plain text. \cr +} + +} + +\subsection{Group: env}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_env_describe_data_frame]{btw_tool_env_describe_data_frame()}} \tab Show the data frame or table or get information about the structure of a data frame or table. \cr + \code{\link[=btw_tool_env_describe_environment]{btw_tool_env_describe_environment()}} \tab List and describe items in an environment. \cr +} + +} + +\subsection{Group: files}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_files_code_search]{btw_tool_files_code_search()}} \tab Search code files in the project. \cr + \code{\link[=btw_tool_files_list_files]{btw_tool_files_list_files()}} \tab List files or directories in the project. \cr + \code{\link[=btw_tool_files_read_text_file]{btw_tool_files_read_text_file()}} \tab Read an entire text file. \cr + \code{\link[=btw_tool_files_write_text_file]{btw_tool_files_write_text_file()}} \tab Write content to a text file. \cr +} + +} + +\subsection{Group: git}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_git_branch_checkout]{btw_tool_git_branch_checkout()}} \tab Switch to a different git branch. \cr + \code{\link[=btw_tool_git_branch_create]{btw_tool_git_branch_create()}} \tab Create a new git branch. \cr + \code{\link[=btw_tool_git_branch_list]{btw_tool_git_branch_list()}} \tab List git branches in the repository. \cr + \code{\link[=btw_tool_git_commit]{btw_tool_git_commit()}} \tab Stage files and create a git commit. \cr + \code{\link[=btw_tool_git_diff]{btw_tool_git_diff()}} \tab View changes in the working directory or a commit. \cr + \code{\link[=btw_tool_git_log]{btw_tool_git_log()}} \tab Show the commit history for a repository. \cr + \code{\link[=btw_tool_git_status]{btw_tool_git_status()}} \tab Show the status of the git working directory. \cr +} + +} + +\subsection{Group: ide}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_ide_read_current_editor]{btw_tool_ide_read_current_editor()}} \tab Read the contents of the editor that is currently open in the user's IDE. \cr +} + +} + +\subsection{Group: search}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_search_package_info]{btw_tool_search_package_info()}} \tab Describe a CRAN package. \cr + \code{\link[=btw_tool_search_packages]{btw_tool_search_packages()}} \tab Search for an R package on CRAN. \cr +} + +} + +\subsection{Group: session}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_session_check_package_installed]{btw_tool_session_check_package_installed()}} \tab Check if a package is installed in the current session. \cr + \code{\link[=btw_tool_session_package_info]{btw_tool_session_package_info()}} \tab Verify that a specific package is installed, or find out which packages are in use in the current session. \cr + \code{\link[=btw_tool_session_platform_info]{btw_tool_session_platform_info()}} \tab Describes the R version, operating system, language and locale settings for the user's system. \cr +} + +} + +\subsection{Group: web}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_web_read_url]{btw_tool_web_read_url()}} \tab Read a web page and convert it to Markdown format. \cr +} + } } \examples{ diff --git a/man/use_btw_md.Rd b/man/use_btw_md.Rd index b19bf757..e5bfe94f 100644 --- a/man/use_btw_md.Rd +++ b/man/use_btw_md.Rd @@ -143,7 +143,7 @@ Here's an example \code{btw.md} file: \if{html}{\out{
}}\preformatted{--- client: claude/claude-4-5-sonnet-latest -tools: [docs, env, files, ide, search, session, web] +tools: [docs, env, files, git, ide, search, session, web] --- Follow these important style rules when writing R code: diff --git a/tests/testthat/_snaps/tool-git.md b/tests/testthat/_snaps/tool-git.md new file mode 100644 index 00000000..de8abf1e --- /dev/null +++ b/tests/testthat/_snaps/tool-git.md @@ -0,0 +1,184 @@ +# btw_tool_git_status() + + Code + cli::cat_line(result@value) + Output + test.txt [new] -unstaged + +--- + + Code + cli::cat_line(result_staged_empty@value) + Output + No changes to report + +--- + + Code + cli::cat_line(result_staged@value) + Output + test.txt [new] +staged + +--- + + Code + cli::cat_line(btw_tool_git_status()@value) + Output + test.txt [new] +staged + +--- + + Code + cli::cat_line(btw_tool_git_status(include = "unstaged")@value) + Output + test.txt [modified] -unstaged + +--- + + Code + cli::cat_line(btw_tool_git_status()@value) + Output + test.txt [modified] -unstaged + +--- + + Code + cli::cat_line(btw_tool_git_status()@value) + Output + test.txt [modified] +staged + +# btw_tool_git_diff() + + Code + cli::cat_line(result@value) + Output + ```diff + diff --git a/test.txt b/test.txt + index abcd123..abcd123 100644 + --- a/test.txt + +++ b/test.txt + @@ -1 +1,2 @@ + line 1 + +line 2 + + ``` + +# btw_tool_git_log() + + Code + cli::cat_line(result@value) + Output + message: Initial commit + author: Test User + time: 2025-10-11 12:13:14 + n_files: 1 + commit: abcd123 + +# btw_tool_git_commit() + + Code + cli::cat_line(result@value) + Output + Created commit: abcd123 + Message: Add test file + +# btw_tool_git_branch_list() + + Code + cli::cat_line(result@value) + Output + main [2025-10-11 12:13:14] + +# btw_tool_git_branch_create() + + Code + cli::cat_line(result@value) + Output + Created branch `feature-branch` from `HEAD`. + +# btw_tool_git_branch_checkout() + + Code + cli::cat_line(result@value) + Output + Checked out branch 'feature' + +# git tools work together + + Code + cli::cat_line(diff_unstaged@value) + Output + ```diff + diff --git a/file1.txt b/file1.txt + index abcd123..abcd123 100644 + --- a/file1.txt + +++ b/file1.txt + @@ -1 +1,2 @@ + Initial content + +Added line + + ``` + +--- + + Code + cli::cat_line(diff_staged@value) + Output + ```diff + diff --git a/file1.txt b/file1.txt + new file mode 100644 + index abcd123..abcd123 + --- /dev/null + +++ b/file1.txt + @@ -0,0 +1 @@ + +Initial content + + ``` + +--- + + Code + cli::cat_line(log_all@value) + Output + message: Update file1 + author: Test User + time: 2025-10-11 12:13:14 + n_files: 1 + commit: abcd123 + + message: Add file1 + author: Test User + time: 2025-10-11 12:13:14 + n_files: 1 + commit: abcd123 + +--- + + Code + cli::cat_line(status_final@value) + Output + file3.txt [new] -unstaged + +--- + + Code + cli::cat_line(log_final@value) + Output + message: Add file2 + author: Test User + time: 2025-10-11 12:13:14 + n_files: 1 + commit: abcd123 + + message: Update file1 + author: Test User + time: 2025-10-11 12:13:14 + n_files: 1 + commit: abcd123 + + message: Add file1 + author: Test User + time: 2025-10-11 12:13:14 + n_files: 1 + commit: abcd123 + diff --git a/tests/testthat/helpers-git.R b/tests/testthat/helpers-git.R new file mode 100644 index 00000000..55be3554 --- /dev/null +++ b/tests/testthat/helpers-git.R @@ -0,0 +1,36 @@ +local_temp_git_repo <- function( + user_name = "Test User", + user_email = "test@example.com", + .local_envir = parent.frame() +) { + repo <- withr::local_tempdir(.local_envir = .local_envir) + withr::local_dir(repo, .local_envir = .local_envir) + gert::git_init(".") + + # Set default branch to 'main' instead of 'master' + gert::git_config_set("init.defaultBranch", "main") + . <- system("git symbolic-ref HEAD refs/heads/main", intern = TRUE) + + gert::git_config_set("user.name", user_name) + gert::git_config_set("user.email", user_email) + + invisible(repo) +} + +scrub_git_details <- function(x) { + # Replace timestamps (YYYY-MM-DD HH:MM:SS format) + x <- gsub( + "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}", + "2025-10-11 12:13:14", + x + ) + + # Replace 7-character commit SHAs + x <- gsub( + "\\b[0-9a-f]{7}\\b", + "abcd123", + x + ) + + x +} diff --git a/tests/testthat/test-tool-git.R b/tests/testthat/test-tool-git.R new file mode 100644 index 00000000..819e7bf6 --- /dev/null +++ b/tests/testthat/test-tool-git.R @@ -0,0 +1,430 @@ +test_that("btw_tool_git_status()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # Empty repo - no changes + result <- btw_tool_git_status() + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "No changes") + + # Create a new file + writeLines("test content", "test.txt") + + result <- btw_tool_git_status() + expect_btw_tool_result(result) + expect_match(result@value, "test\\.txt") + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Check staged only (should be empty) + result_staged_empty <- btw_tool_git_status(include = "staged") + expect_btw_tool_result(result_staged_empty, has_data = FALSE) + expect_match(result_staged_empty@value, "No changes") + expect_snapshot( + cli::cat_line(result_staged_empty@value), + transform = scrub_git_details + ) + + # Stage the file + gert::git_add("test.txt") + + # Check staged only + result_staged <- btw_tool_git_status(include = "staged") + expect_match(result_staged@value, "test\\.txt") + expect_snapshot( + cli::cat_line(result_staged@value), + transform = scrub_git_details + ) + + # Check unstaged only (should be empty) + result_unstaged <- btw_tool_git_status(include = "unstaged") + expect_btw_tool_result(result_unstaged, has_data = FALSE) + expect_match(result_unstaged@value, "No changes") + + cat("\nmore content", file = "test.txt", append = TRUE) + expect_snapshot( + cli::cat_line(btw_tool_git_status()@value), + transform = scrub_git_details + ) + expect_snapshot( + cli::cat_line(btw_tool_git_status(include = "unstaged")@value), + transform = scrub_git_details + ) + + gert::git_commit("Add test.txt") + expect_snapshot( + cli::cat_line(btw_tool_git_status()@value), + transform = scrub_git_details + ) + + gert::git_add("test.txt") + expect_snapshot( + cli::cat_line(btw_tool_git_status()@value), + transform = scrub_git_details + ) +}) + +test_that("btw_tool_git_diff()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # No changes initially + result <- btw_tool_git_diff() + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "No.*changes") + + # Create and stage a file + writeLines("line 1", "test.txt") + gert::git_add("test.txt") + gert::git_commit("Initial commit") + + # Modify the file + writeLines(c("line 1", "line 2"), "test.txt") + + # Check unstaged diff (working dir vs index) + result <- btw_tool_git_diff() + expect_btw_tool_result(result, has_data = FALSE) + expect_true(any(grepl("line 2", result@value, fixed = TRUE))) + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Stage the changes + gert::git_add("test.txt") + + # Check staged diff (index vs HEAD) - shows both lines being modified from HEAD + result_staged <- btw_tool_git_diff(ref = "HEAD") + expect_btw_tool_result(result_staged, has_data = FALSE) + # The staged diff shows the change from HEAD, which includes adding line 2 + expect_true(any(grepl("@@", result_staged@value, fixed = TRUE))) # Has diff hunks + + # Commit and verify no more unstaged changes + gert::git_commit("Add line 2") + result_no_changes <- btw_tool_git_diff() + expect_btw_tool_result(result_no_changes, has_data = FALSE) + expect_match(result_no_changes@value, "No.*changes") +}) + +test_that("btw_tool_git_log()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # Create initial commit + writeLines("test", "test.txt") + gert::git_add("test.txt") + gert::git_commit("Initial commit") + + result <- btw_tool_git_log() + expect_btw_tool_result(result) + expect_match(result@value, "Initial commit") + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Test max parameter + writeLines("test2", "test2.txt") + gert::git_add("test2.txt") + gert::git_commit("Second commit") + + result_limited <- btw_tool_git_log(max = 1) + expect_btw_tool_result(result_limited) + expect_match(result_limited@value, "Second commit") +}) + +test_that("btw_tool_git_commit()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # Create a file + writeLines("test content", "test.txt") + + # Commit with files parameter + result <- btw_tool_git_commit( + message = "Add test file", + files = "test.txt" + ) + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "Add test file") + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Verify commit was created + log <- gert::git_log(max = 1) + expect_equal(nrow(log), 1) + expect_match(log$message, "Add test file") + + # Test committing already staged files + writeLines("more content", "test2.txt") + gert::git_add("test2.txt") + + result2 <- btw_tool_git_commit( + message = "Add second file", + files = NULL + ) + expect_btw_tool_result(result2, has_data = FALSE) + expect_match(result2@value, "Add second file") +}) + +test_that("btw_tool_git_branch_list()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # Create initial commit (needed for branches to show up) + writeLines("test", "test.txt") + gert::git_add("test.txt") + gert::git_commit("Initial commit") + + result <- btw_tool_git_branch_list() + expect_btw_tool_result(result) + # Should show at least the default branch (main or master) + expect_true(nrow(result@extra$data) >= 1) + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Create a new branch + gert::git_branch_create("feature", checkout = FALSE) + + result2 <- btw_tool_git_branch_list() + expect_btw_tool_result(result2) + expect_true(nrow(result2@extra$data) >= 2) + expect_match(result2@value, "feature") +}) + +test_that("btw_tool_git_branch_create()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # Create initial commit + writeLines("test", "test.txt") + gert::git_add("test.txt") + gert::git_commit("Initial commit") + + result <- btw_tool_git_branch_create( + branch = "feature-branch", + checkout = FALSE + ) + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "feature-branch") + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Verify branch exists + branches <- gert::git_branch_list() + expect_true("feature-branch" %in% branches$name) +}) + +test_that("btw_tool_git_branch_checkout()", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + # Create initial commit + writeLines("test", "test.txt") + gert::git_add("test.txt") + gert::git_commit("Initial commit") + + # Get current branch name + current_branch <- gert::git_branch() + + # Create and checkout new branch + gert::git_branch_create("feature", checkout = FALSE) + + result <- btw_tool_git_branch_checkout(branch = "feature") + expect_btw_tool_result(result, has_data = FALSE) + expect_match(result@value, "feature") + expect_snapshot(cli::cat_line(result@value), transform = scrub_git_details) + + # Verify we're on the new branch + expect_equal(gert::git_branch(), "feature") + + # Checkout back to original branch + result2 <- btw_tool_git_branch_checkout(branch = current_branch) + expect_btw_tool_result(result2, has_data = FALSE) + expect_equal(gert::git_branch(), current_branch) +}) + +test_that("git tools require gert to be installed", { + skip_if_not_installed("gert") + + local_temp_git_repo() + + local_mocked_bindings( + is_installed = function(pkg) pkg != "gert" + ) + + expect_error( + btw_tool_git_status(), + "gert" + ) + + expect_error( + btw_tool_git_diff(), + "gert" + ) + + expect_error( + btw_tool_git_log(), + "gert" + ) + + expect_error( + btw_tool_git_commit(message = "test", files = "test.txt"), + "gert" + ) + + expect_error( + btw_tool_git_branch_list(), + "gert" + ) + + expect_error( + btw_tool_git_branch_create(branch = "test"), + "gert" + ) + + expect_error( + btw_tool_git_branch_checkout(branch = "main"), + "gert" + ) +}) + +test_that("git tools validate arguments", { + skip_if_not_installed("gert") + local_temp_git_repo() + + # btw_tool_git_status + expect_error(btw_tool_git_status(include = "invalid")) + expect_error(btw_tool_git_status(pathspec = 123)) + + # btw_tool_git_diff + expect_error(btw_tool_git_diff(ref = 123)) + + # btw_tool_git_log + expect_error(btw_tool_git_log(ref = 123)) + expect_error(btw_tool_git_log(max = "invalid")) + expect_error(btw_tool_git_log(max = 0)) + + # btw_tool_git_commit + expect_error(btw_tool_git_commit(message = 123)) + expect_error(btw_tool_git_commit(message = "test", files = 123)) + + # btw_tool_git_branch_list + expect_error(btw_tool_git_branch_list(local = "invalid")) + + # btw_tool_git_branch_create + expect_error(btw_tool_git_branch_create(branch = 123)) + expect_error(btw_tool_git_branch_create(branch = "test", ref = 123)) + expect_error( + btw_tool_git_branch_create(branch = "test", checkout = "invalid") + ) + + # btw_tool_git_branch_checkout + expect_error(btw_tool_git_branch_checkout(branch = 123)) + expect_error(btw_tool_git_branch_checkout(branch = "test", force = "invalid")) +}) + +test_that("git tools work together", { + skip_if_not_installed("gert") + local_temp_git_repo() + + # Workflow 1: Check status, stage, commit, verify log + # Create some files + writeLines("Initial content", "file1.txt") + writeLines("More content", "file2.txt") + + # Check status to see untracked files + status1 <- btw_tool_git_status() + expect_match(status1@value, "file1\\.txt") + expect_match(status1@value, "file2\\.txt") + + # Stage and commit first file + commit1 <- btw_tool_git_commit( + message = "Add file1", + files = "file1.txt" + ) + expect_btw_tool_result(commit1, has_data = FALSE) + + # Verify log shows the commit + log1 <- btw_tool_git_log(max = 1) + expect_match(log1@value, "Add file1") + + # Extract commit SHA from log data to use in diff + commit_sha <- log1@extra$data$commit[1] + expect_false(grepl("[^a-f0-9]", commit_sha)) # Should be hex SHA + + # Workflow 2: Make changes, check diff, stage, commit + # Modify file1 + writeLines(c("Initial content", "Added line"), "file1.txt") + + # Check diff shows unstaged changes + diff_unstaged <- btw_tool_git_diff() + expect_btw_tool_result(diff_unstaged, has_data = FALSE) + # Should show a diff with changes + expect_true(any(grepl("@@", diff_unstaged@value, fixed = TRUE))) + expect_snapshot( + cli::cat_line(diff_unstaged@value), + transform = scrub_git_details + ) + + # Stage the changes + gert::git_add("file1.txt") + + # Check diff shows staged changes + diff_staged <- btw_tool_git_diff(ref = "HEAD") + expect_btw_tool_result(diff_staged, has_data = FALSE) + # Should show a diff with changes + expect_true(any(grepl("@@", diff_staged@value, fixed = TRUE))) + expect_snapshot( + cli::cat_line(diff_staged@value), + transform = scrub_git_details + ) + + # Commit the changes + commit2 <- btw_tool_git_commit( + message = "Update file1", + files = NULL # Already staged + ) + expect_btw_tool_result(commit2, has_data = FALSE) + + # Workflow 3: Use log to find commits + # Get log of both commits + log_all <- btw_tool_git_log(max = 10) + expect_btw_tool_result(log_all) + expect_equal(nrow(log_all@extra$data), 2) + expect_match(log_all@value, "Add file1") + expect_match(log_all@value, "Update file1") + expect_snapshot(cli::cat_line(log_all@value), transform = scrub_git_details) + + # Get commits SHAs from log - verifies we can use log data + commit_shas <- log_all@extra$data$commit + expect_equal(length(commit_shas), 2) + # All should be hex SHAs + expect_true(!any(grepl("[^a-f0-9]", commit_shas))) + + # Workflow 4: Multiple file workflow with status tracking + # Add second file + commit3 <- btw_tool_git_commit( + message = "Add file2", + files = "file2.txt" + ) + expect_btw_tool_result(commit3, has_data = FALSE) + + # Create third file but don't commit + writeLines("Third file", "file3.txt") + + # Status should show file3 as untracked, others committed + status_final <- btw_tool_git_status() + expect_match(status_final@value, "file3\\.txt") + expect_no_match(status_final@value, "file1\\.txt") + expect_no_match(status_final@value, "file2\\.txt") + expect_snapshot( + cli::cat_line(status_final@value), + transform = scrub_git_details + ) + + # Verify log shows all three commits + log_final <- btw_tool_git_log(max = 10) + expect_equal(nrow(log_final@extra$data), 3) + expect_match(log_final@value, "Add file1") + expect_match(log_final@value, "Update file1") + expect_match(log_final@value, "Add file2") + expect_snapshot(cli::cat_line(log_final@value), transform = scrub_git_details) +})