R/git_hooks.R

Defines functions .update_hook_config .generate_hook_script .get_hook_setting .require_git_repo .git_available .is_git_repo git_hooks_list git_hooks_disable git_hooks_enable git_hooks_uninstall git_hooks_install git_log git_diff git_pull git_push git_commit git_add git_status

Documented in .generate_hook_script .get_hook_setting git_add .git_available git_commit git_diff git_hooks_disable git_hooks_enable git_hooks_install git_hooks_list git_hooks_uninstall git_log git_pull git_push git_status .is_git_repo .require_git_repo .update_hook_config

# Git Helper Functions ----------------------------------------------------

#' Show Git Status
#'
#' Display the working tree status from the R console.
#'
#' @param short Logical; if TRUE, show short format (default: FALSE)
#'
#' @return Invisibly returns the status output as a character vector
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_status()
#' git_status(short = TRUE)
#' }
#' }
#'
#' @export
git_status <- function(short = FALSE) {
  .require_git_repo()

  args <- "status"
  if (short) args <- c(args, "--short")

  result <- system2("git", args, stdout = TRUE, stderr = TRUE)
  message(paste(result, collapse = "\n"))
  invisible(result)
}


#' Stage Files for Commit
#'
#' Add file contents to the staging area.
#'
#' @param files Character vector of file paths to stage, or "." for all (default)
#'
#' @return Invisibly returns TRUE on success
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_add()              # Stage all changes
#' git_add("README.md")   # Stage specific file
#' git_add(c("R/foo.R", "R/bar.R"))
#' }
#' }
#'
#' @export
git_add <- function(files = ".") {
  .require_git_repo()

  result <- system2("git", c("add", files), stdout = TRUE, stderr = TRUE)

  if (length(files) == 1 && files == ".") {
    message("\u2713 Staged all changes")
  } else {
    message("\u2713 Staged: ", paste(files, collapse = ", "))
  }

  invisible(TRUE)
}


#' Commit Staged Changes
#'
#' Record changes to the repository with a commit message.
#'
#' @param message Commit message (required)
#' @param all Logical; if TRUE, automatically stage modified/deleted files (default: FALSE)
#'
#' @return Invisibly returns TRUE on success
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_commit("Fix bug in data loading")
#' git_commit("Update README", all = TRUE)  # Stage and commit
#' }
#' }
#'
#' @export
git_commit <- function(message, all = FALSE) {
  .require_git_repo()

  if (missing(message) || is.null(message) || message == "") {
    stop("Commit message is required")
  }

  # Write message to a temporary file to avoid shell/spacing issues
  msg_file <- tempfile("framework_commit_msg_")
  writeLines(message, msg_file)
  on.exit(unlink(msg_file), add = TRUE)

  args <- c("commit", "-F", msg_file)
  if (all) args <- c(args, "-a")

  result <- system2("git", args, stdout = TRUE, stderr = TRUE)
  status <- attr(result, "status")

  if (!is.null(status) && status != 0) {
    message(paste(result, collapse = "\n"))
    stop("Commit failed")
  }

  message("\u2713 Committed: ", message)
  invisible(TRUE)
}


#' Push to Remote
#'
#' Push commits to the remote repository.
#'
#' @param remote Remote name (default: "origin")
#' @param branch Branch name (default: current branch)
#'
#' @return Invisibly returns TRUE on success
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_push()
#' git_push(remote = "origin", branch = "main")
#' }
#' }
#'
#' @export
git_push <- function(remote = "origin", branch = NULL) {
  .require_git_repo()

  args <- c("push", remote)
  if (!is.null(branch)) args <- c(args, branch)

  message("Pushing to ", remote, "...")
  result <- system2("git", args, stdout = TRUE, stderr = TRUE)
  status <- attr(result, "status")

  if (!is.null(status) && status != 0) {
    message(paste(result, collapse = "\n"))
    stop("Push failed")
  }

  message("\u2713 Pushed successfully")
  invisible(TRUE)
}


#' Pull from Remote
#'
#' Fetch and integrate changes from the remote repository.
#'
#' @param remote Remote name (default: "origin")
#' @param branch Branch name (default: current branch)
#'
#' @return Invisibly returns TRUE on success
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_pull()
#' git_pull(remote = "origin", branch = "main")
#' }
#' }
#'
#' @export
git_pull <- function(remote = "origin", branch = NULL) {
  .require_git_repo()

  args <- c("pull", remote)
  if (!is.null(branch)) args <- c(args, branch)

  message("Pulling from ", remote, "...")
  result <- system2("git", args, stdout = TRUE, stderr = TRUE)
  status <- attr(result, "status")

  if (!is.null(status) && status != 0) {
    message(paste(result, collapse = "\n"))
    stop("Pull failed")
  }

  message(paste(result, collapse = "\n"))
  invisible(TRUE)
}


#' Show Changes (Diff)
#'
#' Show changes between commits, working tree, etc.
#'
#' @param staged Logical; if TRUE, show staged changes (default: FALSE shows unstaged)
#' @param file Optional file path to show diff for specific file
#'
#' @return Invisibly returns the diff output as a character vector
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_diff()             # Show unstaged changes
#' git_diff(staged = TRUE) # Show staged changes
#' git_diff(file = "R/foo.R")
#' }
#' }
#'
#' @export
git_diff <- function(staged = FALSE, file = NULL) {
  .require_git_repo()

  args <- "diff"
  if (staged) args <- c(args, "--staged")
  if (!is.null(file)) args <- c(args, file)

  result <- system2("git", args, stdout = TRUE, stderr = TRUE)

  if (length(result) == 0) {
    message("No changes")
  } else {
    message(paste(result, collapse = "\n"))
  }

  invisible(result)
}


#' Show Commit Log
#'
#' Show recent commit history.
#'
#' @param n Number of commits to show (default: 10)
#' @param oneline Logical; if TRUE, show condensed one-line format (default: TRUE)
#'
#' @return Invisibly returns the log output as a character vector
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_log()
#' git_log(n = 5)
#' git_log(oneline = FALSE)  # Full format
#' }
#' }
#'
#' @export
git_log <- function(n = 10, oneline = TRUE) {
  .require_git_repo()

  args <- c("log", paste0("-", n))
  if (oneline) args <- c(args, "--oneline")

  result <- system2("git", args, stdout = TRUE, stderr = TRUE)
  message(paste(result, collapse = "\n"))
  invisible(result)
}


# Git Hooks Management ----------------------------------------------------

#' Install Git Pre-commit Hook
#'
#' Creates a pre-commit hook that runs Framework checks based on settings.yml settings.
#'
#' @param config_file Path to configuration file (default: "settings.yml")
#' @param force Logical; if TRUE, overwrite existing hook (default: FALSE)
#' @param verbose Logical; if TRUE (default), show installation messages
#'
#' @return Invisible TRUE on success, FALSE on failure
#'
#' @details
#' Creates or updates `.git/hooks/pre-commit` to run enabled Framework hooks:
#' \itemize{
#'   \item **ai_sync**: Sync AI assistant context files before commit
#'   \item **data_security**: Run security audit to catch data leaks
#'   \item **check_sensitive_dirs**: Warn about unignored sensitive directories
#' }
#'
#' Hook behavior is controlled by `git.hooks.*` settings in settings.yml.
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' # Install hooks based on settings.yml
#' git_hooks_install()
#'
#' # Force reinstall (overwrites existing hook)
#' git_hooks_install(force = TRUE)
#' }
#' }
#'
#' @export
git_hooks_install <- function(config_file = NULL,
                              force = FALSE,
                              verbose = TRUE) {

  # Check if git repo
  if (!.is_git_repo()) {
    if (verbose) {
      message("\u2717 Not a git repository")
    }
    return(invisible(FALSE))
  }

  # Auto-discover settings file if not specified
  if (is.null(config_file)) {
    config_file <- .get_settings_file()
  }

  if (is.null(config_file)) {
    if (verbose) {
      message("\u2717 Settings file not found")
    }
    return(invisible(FALSE))
  }

  # Check if config exists
  if (!file.exists(config_file)) {
    if (verbose) {
      message("\u2717 Config file not found: ", config_file)
    }
    return(invisible(FALSE))
  }

  # Read hook configuration
  ai_sync_enabled <- .get_hook_setting("git.hooks.ai_sync", config_file = config_file, default = FALSE)
  data_security_enabled <- .get_hook_setting("git.hooks.data_security", config_file = config_file, default = FALSE)
  check_sensitive_dirs_enabled <- .get_hook_setting(
    "git.hooks.check_sensitive_dirs",
    alias = "git.hooks.warn_unignored_sensitive",
    config_file = config_file,
    default = FALSE
  )

  if (!ai_sync_enabled && !data_security_enabled && !check_sensitive_dirs_enabled) {
    if (verbose) {
      message("\u2139 No hooks enabled in settings.yml/config.yml")
      message("  Enable with: config$git$hooks$ai_sync = TRUE")
    }
    return(invisible(FALSE))
  }

  # Create .git/hooks directory if needed
  hooks_dir <- ".git/hooks"
  if (!dir.exists(hooks_dir)) {
    dir.create(hooks_dir, recursive = TRUE)
  }

  hook_path <- file.path(hooks_dir, "pre-commit")

  # Check if hook already exists
  if (file.exists(hook_path) && !force) {
    if (verbose) {
      message("\u2717 Pre-commit hook already exists")
      message("  Use force=TRUE to overwrite or edit .git/hooks/pre-commit manually")
    }
    return(invisible(FALSE))
  }

  # Generate hook script
  hook_script <- .generate_hook_script(ai_sync_enabled, data_security_enabled, check_sensitive_dirs_enabled)

  # Write hook
  tryCatch({
    writeLines(hook_script, hook_path)
    Sys.chmod(hook_path, mode = "0755")  # Make executable

    if (verbose) {
      message("\u2713 Installed pre-commit hook")
      if (ai_sync_enabled) {
        message("  \u2022 AI context sync enabled")
      }
      if (data_security_enabled) {
        message("  \u2022 Data security check enabled")
      }
      if (check_sensitive_dirs_enabled) {
        message("  \u2022 Sensitive directories check enabled")
      }
    }

    invisible(TRUE)
  }, error = function(e) {
    if (verbose) {
      message("\u2717 Failed to install hook: ", e$message)
    }
    invisible(FALSE)
  })
}


#' Uninstall Git Pre-commit Hook
#'
#' Removes the Framework-managed pre-commit hook.
#'
#' @param verbose Logical; if TRUE (default), show messages
#'
#' @return Invisible TRUE if hook was removed, FALSE otherwise
#'
#' @export
git_hooks_uninstall <- function(verbose = TRUE) {
  hook_path <- ".git/hooks/pre-commit"

  if (!file.exists(hook_path)) {
    if (verbose) {
      message("\u2139 No pre-commit hook found")
    }
    return(invisible(FALSE))
  }

  # Check if it's a Framework-managed hook
  hook_content <- readLines(hook_path, warn = FALSE)
  is_framework_hook <- any(grepl("Framework Git Hooks", hook_content))

  if (!is_framework_hook) {
    if (verbose) {
      message("\u26a0 Pre-commit hook exists but not managed by Framework")
      message("  Delete .git/hooks/pre-commit manually if needed")
    }
    return(invisible(FALSE))
  }

  # Remove hook
  tryCatch({
    file.remove(hook_path)
    if (verbose) {
      message("\u2713 Removed pre-commit hook")
    }
    invisible(TRUE)
  }, error = function(e) {
    if (verbose) {
      message("\u2717 Failed to remove hook: ", e$message)
    }
    invisible(FALSE)
  })
}


#' Enable Specific Git Hook
#'
#' Enables a specific hook in settings and reinstalls the pre-commit hook.
#'
#' @param hook_name Name of hook: "ai_sync", "data_security", or "check_sensitive_dirs"
#' @param config_file Path to configuration file (default: auto-discover settings.yml or settings.yml)
#' @param verbose Logical; if TRUE (default), show messages
#'
#' @return Invisible TRUE on success
#'
#' @examples
#' \donttest{
#' if (FALSE) {
#' git_hooks_enable("ai_sync")
#' git_hooks_enable("data_security")
#' }
#' }
#'
#' @export
git_hooks_enable <- function(hook_name, config_file = NULL, verbose = TRUE) {
  valid_hooks <- c("ai_sync", "data_security", "check_sensitive_dirs", "warn_unignored_sensitive")

  if (!hook_name %in% valid_hooks) {
    stop("Invalid hook name. Must be one of: ", paste(valid_hooks, collapse = ", "))
  }

  # Auto-discover settings file if not specified
  if (is.null(config_file)) {
    config_file <- .get_settings_file()
    if (is.null(config_file)) {
      stop("Settings file not found")
    }
  }

  # Update settings.yml/config.yml
  .update_hook_config(hook_name, TRUE, config_file)

  if (verbose) {
    message("\u2713 Enabled ", hook_name, " hook")
  }

  # Reinstall hooks
  git_hooks_install(config_file = config_file, force = TRUE, verbose = verbose)
}


#' Disable Specific Git Hook
#'
#' Disables a specific hook in settings and reinstalls the pre-commit hook.
#'
#' @param hook_name Name of hook: "ai_sync", "data_security", or "check_sensitive_dirs"
#' @param config_file Path to configuration file (default: auto-discover settings.yml or settings.yml)
#' @param verbose Logical; if TRUE (default), show messages
#'
#' @return Invisible TRUE on success
#'
#' @export
git_hooks_disable <- function(hook_name, config_file = NULL, verbose = TRUE) {
  valid_hooks <- c("ai_sync", "data_security", "check_sensitive_dirs", "warn_unignored_sensitive")

  if (!hook_name %in% valid_hooks) {
    stop("Invalid hook name. Must be one of: ", paste(valid_hooks, collapse = ", "))
  }

  # Auto-discover settings file if not specified
  if (is.null(config_file)) {
    config_file <- .get_settings_file()
    if (is.null(config_file)) {
      stop("Settings file not found")
    }
  }

  # Update settings.yml/config.yml
  .update_hook_config(hook_name, FALSE, config_file)

  if (verbose) {
    message("\u2713 Disabled ", hook_name, " hook")
  }

  # Reinstall hooks (or uninstall if all disabled)
  ai_sync_enabled <- .get_hook_setting("git.hooks.ai_sync", config_file = config_file, default = FALSE)
  data_security_enabled <- .get_hook_setting("git.hooks.data_security", config_file = config_file, default = FALSE)
  check_sensitive_dirs_enabled <- .get_hook_setting(
    "git.hooks.check_sensitive_dirs",
    alias = "git.hooks.warn_unignored_sensitive",
    config_file = config_file,
    default = FALSE
  )

  if (!ai_sync_enabled && !data_security_enabled && !check_sensitive_dirs_enabled) {
    git_hooks_uninstall(verbose = verbose)
  } else {
    git_hooks_install(config_file = config_file, force = TRUE, verbose = verbose)
  }
}


#' List Git Hook Status
#'
#' Shows which hooks are enabled and their current status.
#'
#' @param config_file Path to configuration file (default: auto-discover settings.yml or settings.yml)
#'
#' @return Data frame with hook information
#'
#' @export
git_hooks_list <- function(config_file = NULL) {
  # Auto-discover settings file if not specified
  if (is.null(config_file)) {
    config_file <- .get_settings_file()
  }

  if (is.null(config_file)) {
    message("\u2717 Settings file not found")
    return(invisible(NULL))
  }

  if (!file.exists(config_file)) {
    message("\u2717 Config file not found: ", config_file)
    return(invisible(NULL))
  }

  ai_sync <- .get_hook_setting("git.hooks.ai_sync", config_file = config_file, default = FALSE)
  data_security <- .get_hook_setting("git.hooks.data_security", config_file = config_file, default = FALSE)
  check_sensitive_dirs <- .get_hook_setting(
    "git.hooks.check_sensitive_dirs",
    alias = "git.hooks.warn_unignored_sensitive",
    config_file = config_file,
    default = FALSE
  )

  hook_installed <- file.exists(".git/hooks/pre-commit")

  df <- data.frame(
    hook = c("ai_sync", "data_security", "check_sensitive_dirs"),
    enabled = c(ai_sync, data_security, check_sensitive_dirs),
    description = c(
      "Sync AI assistant context files",
      "Check for secrets/credentials",
      "Warn about unignored sensitive directories"
    ),
    stringsAsFactors = FALSE
  )

  message("=== Framework Git Hooks ===\n")
  message("Pre-commit hook: ", if (hook_installed) "installed" else "not installed")
  message("")

  for (i in seq_len(nrow(df))) {
    status_icon <- if (df$enabled[i]) "\u2713" else "\u2717"
    message(sprintf("%s %s - %s", status_icon, df$hook[i], df$description[i]))
  }

  message("\nCommands:")
  message("  git_hooks_enable(\"ai_sync\")")
  message("  git_hooks_disable(\"ai_sync\")")
  message("  git_hooks_install()")
  message("  git_hooks_uninstall()")

  invisible(df)
}


# Internal helpers --------------------------------------------------------

#' Check if current directory is a git repository
#' @keywords internal
.is_git_repo <- function() {
  dir.exists(".git")
}

#' Check if git is available on the system
#' @return TRUE if git is available, FALSE otherwise
#' @keywords internal
.git_available <- function() {
  nzchar(Sys.which("git"))
}

#' Require git repository or stop
#' @keywords internal
.require_git_repo <- function() {
  if (!.git_available()) {
    stop("Git is not installed or not in PATH. Please install git to use this feature.")
  }
  if (!.is_git_repo()) {
    stop("Not a git repository. Run 'git init' first.")
  }
}

#' Get git hook setting with optional alias fallback
#' @keywords internal
.get_hook_setting <- function(key, alias = NULL, config_file = NULL, default = FALSE) {
  value <- config(key, config_file = config_file, default = default)
  if (!is.null(alias) && identical(value, default)) {
    value <- config(alias, config_file = config_file, default = default)
  }
  value
}


#' Generate pre-commit hook script
#' @keywords internal
.generate_hook_script <- function(ai_sync_enabled, data_security_enabled, check_sensitive_dirs_enabled) {
  c(
    "#!/usr/bin/env bash",
    "# Framework Git Hooks",
    "# Managed by Framework - do not edit manually",
    "# Use git_hooks_*() functions to configure",
    "",
    "set -e",
    "",
    "# Get project root",
    "PROJECT_ROOT=\"$(git rev-parse --show-toplevel)\"",
    "cd \"$PROJECT_ROOT\"",
    "",
    if (check_sensitive_dirs_enabled) c(
      "# Check for unignored sensitive directories",
      "SENSITIVE_PATTERNS=('*private*' '*confidential*' '*sensitive*' '*cache*' '*scratch*')",
      "UNIGNORED_DIRS=()",
      "",
      "# Helper: check if a path or any ancestor is gitignored",
      "is_path_ignored() {",
      "  local p=\"$1\"",
      "  while [ \"$p\" != \".\" ] && [ \"$p\" != \"/\" ]; do",
      "    if git check-ignore -q --no-index \"$p\" 2>/dev/null; then",
      "      return 0",
      "    fi",
      "    p=\"$(dirname \"$p\")\"",
      "  done",
      "  return 1",
      "}",
      "",
      "for pattern in \"${SENSITIVE_PATTERNS[@]}\"; do",
      "  while IFS= read -r -d '' dir; do",
      "    # Skip .git internals",
      "    [[ \"$dir\" == \"./.git\"* ]] && continue",
      "    ",
      "    # Check if directory or any ancestor is gitignored",
      "    if ! is_path_ignored \"$dir\"; then",
      "      UNIGNORED_DIRS+=(\"$dir\")",
      "    fi",
      "  done < <(find . -type d -iname \"$pattern\" -print0 2>/dev/null)",
      "done",
      "",
      "if [ ${#UNIGNORED_DIRS[@]} -gt 0 ]; then",
      "  echo \"\u26a0 Warning: Found sensitive directories that are NOT gitignored:\"",
      "  for dir in \"${UNIGNORED_DIRS[@]}\"; do",
      "    echo \"  - $dir\"",
      "  done",
      "  echo \"\"",
      "  echo \"These directories may contain sensitive data and should be added to .gitignore.\"",
      "  echo \"Pattern-based .gitignore entries like '**/private/**' will automatically catch them.\"",
      "  echo \"\"",
      "  # Prompt user if running interactively, otherwise just warn",
      "  if [ -t 0 ] || exec < /dev/tty 2>/dev/null; then",
      "    read -p \"Continue with commit anyway? (y/N) \" -n 1 -r",
      "    echo",
      "    if [[ ! $REPLY =~ ^[Yy]$ ]]; then",
      "      echo \"Commit aborted. Add directories to .gitignore and try again.\"",
      "      exit 1",
      "    fi",
      "  else",
      "    echo \"(Non-interactive mode: continuing with warning)\"",
      "  fi",
      "fi",
      ""
    ) else NULL,
    if (ai_sync_enabled) c(
      "# AI context sync",
      "if ! Rscript -e \"library(framework); ai_sync_context(verbose=FALSE)\"; then",
      "  echo \"\u2717 AI context sync failed\"",
      "  exit 1",
      "fi",
      "",
      "# Add any files modified by AI sync",
      "git add CLAUDE.md AGENTS.md .github/copilot-instructions.md 2>/dev/null || true",
      ""
    ) else NULL,
    if (data_security_enabled) c(
      "# Data security check",
      "if ! Rscript -e \"library(framework); result <- git_security_audit(verbose=FALSE); if (any(result\\$summary\\$status == 'fail')) quit(status=1)\"; then",
      "  echo \"\u2717 Data security check failed\"",
      "  echo \"Run 'framework security:audit' for details\"",
      "  exit 1",
      "fi",
      ""
    ) else NULL,
    "exit 0"
  )
}


#' Update hook configuration in settings.yml/settings.yml
#' @keywords internal
.update_hook_config <- function(hook_name, enabled, config_file) {
  if (!file.exists(config_file)) {
    stop("Config file not found: ", config_file)
  }

  # Read config
  config_content <- yaml::read_yaml(config_file, eval.expr = FALSE)

  has_envs <- !is.null(config_content$default) && is.list(config_content$default)
  env_config <- if (has_envs) config_content$default else config_content

  # Ensure structure exists
  if (is.null(env_config$git)) {
    env_config$git <- list()
  }
  if (is.null(env_config$git$hooks)) {
    env_config$git$hooks <- list()
  }

  # Update hook setting
  env_config$git$hooks[[hook_name]] <- enabled

  if (has_envs) {
    config_content$default <- env_config
  } else {
    config_content <- env_config
  }

  # Write back
  yaml::write_yaml(config_content, config_file)

  invisible(TRUE)
}

Try the framework package in your browser

Any scripts or data that you put into this service are public.

framework documentation built on Feb. 18, 2026, 1:07 a.m.