diff --git a/Cargo.lock b/Cargo.lock index f052eaab..c449cf82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,7 +652,7 @@ dependencies = [ [[package]] name = "git-ai" -version = "1.0.1" +version = "1.0.2" dependencies = [ "assert_cmd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 9758140d..8d39f38a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-ai" -version = "1.0.1" +version = "1.0.2" edition = "2024" diff --git a/docs/enterprise-configuration.mdx b/docs/enterprise-configuration.mdx new file mode 100644 index 00000000..a996ce23 --- /dev/null +++ b/docs/enterprise-configuration.mdx @@ -0,0 +1,82 @@ +--- +title: Enterprise Configuration +--- + +`git-ai`'s behavior can be configured on developer machines by writing a JSON file in the user's home directory. + +On Linux and macOS, this file is located at `$HOME/.git-ai/config.json`. +On Windows, this file is located at `%USERPROFILE%\.git-ai\config.json`. + +## Options + +All the options in `config.json` are optional, and will fall back to default values if not provided. + +| --- | --- | --- | +| `git_path` `Path` | The path to the (unaltered) `git` binary you distribute on developer machines | Defaults to whichever git is on the shell path | +| `ignore_prompts` `boolean` flag | Prompts be excluded from authorship logs | `false` | +| `allow_repositories` `Path[]` | Allow `git-ai` in only these remotes | If not specified or set to an empty list, all repositories are allowed. | + +```json +{ + "git_path": "/usr/bin/git", + "ignore_prompts": false, + "allow_repositories": [ + "https://github.com/acunniffe/git-ai.git" + ] +} +``` + +## Installing `git-ai` binary on developer machines + +When `git-ai` is installed using the [`install.sh` script](https://github.com/acunniffe/git-ai?tab=readme-ov-file#install) (reccomended for personal use) the downloaded binary will be configured to handle calls to both `git` and `git-ai`, effectively creating a wrapper/proxy to `git`. + +If you would like to create a custom installation here is what `git-ai` requires to works correctly cross platform: + +### Directory Structure + +**Unix/Linux/macOS:** +- Install the `git-ai` binary to: `$HOME/.git-ai/bin/git-ai` +- Create a symlink: `$HOME/.git-ai/bin/git` → `$HOME/.git-ai/bin/git-ai` +- Create a symlink: `$HOME/.git-ai/bin/git-og` → `/path/to/original/git` +- Make the binary executable: `chmod +x $HOME/.git-ai/bin/git-ai` +- On macOS only: Remove quarantine attribute: `xattr -d com.apple.quarantine $HOME/.git-ai/bin/git-ai` + +**Windows:** +- Install the binary to: `%USERPROFILE%\.git-ai\bin\git-ai.exe` +- Create a copy: `%USERPROFILE%\.git-ai\bin\git.exe` (copy of `git-ai.exe`) +- Create a batch file: `%USERPROFILE%\.git-ai\bin\git-og.cmd` that calls the original git executable +- Unblock the downloaded files (PowerShell: `Unblock-File`) + +### PATH Configuration + +**Unix/Linux/macOS:** +- Add `$HOME/.git-ai/bin` to the beginning of the user's PATH +- Update the appropriate shell config file (`.zshrc`, `.bashrc`, etc.) + +**Windows:** +- Add `%USERPROFILE%\.git-ai\bin` to the System PATH +- The directory should be positioned **before** any existing Git installation directories to ensure the git-ai shim takes precedence + +### Configuration File + +Create `$HOME/.git-ai/config.json` (or `%USERPROFILE%\.git-ai\config.json` on Windows) with the options outlined at the top of this page. + +### IDE/Agent Hook Installation + +After installing the binary and configuring PATH, run: + +```bash +git-ai install-hooks +``` + +This sets up integration with supported IDEs and AI coding agents (Cursor, VS Code with GitHub Copilot, etc.). + +### Reference Implementation + +Our official install scripts implement all of these requirements and can serve as references: +- Unix/Linux/macOS: [`install.sh`](https://github.com/acunniffe/git-ai/blob/main/install.sh) +- Windows: [`install.ps1`](https://github.com/acunniffe/git-ai/blob/main/install.ps1) + +These scripts handle edge cases like detecting the original git path, preventing recursive installations, and gracefully handling errors. + + diff --git a/src/commands/git_handlers.rs b/src/commands/git_handlers.rs index c2322db8..3849fc32 100644 --- a/src/commands/git_handlers.rs +++ b/src/commands/git_handlers.rs @@ -81,8 +81,17 @@ pub fn handle_git(args: &[String]) { // println!("command_args: {:?}", parsed_args.command_args); // println!("to_invocation_vec: {:?}", parsed_args.to_invocation_vec()); + let config = config::Config::get(); + + let skip_hooks = !config.is_allowed_repository(&repository_option); + if skip_hooks { + debug_log( + "Skipping git-ai hooks because repository does not have at least one remote in allow_repositories list", + ); + } + // run with hooks - let exit_status = if !parsed_args.is_help && has_repo { + let exit_status = if !parsed_args.is_help && has_repo && !skip_hooks { let repository = repository_option.as_mut().unwrap(); run_pre_command_hooks(&mut command_hooks_context, &parsed_args, repository); let exit_status = proxy_to_git(&parsed_args.to_invocation_vec(), false); diff --git a/src/config.rs b/src/config.rs index 3a040d5d..b2b1f82f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -5,10 +6,13 @@ use std::sync::OnceLock; use serde::Deserialize; +use crate::git::repository::Repository; + /// Centralized configuration for the application pub struct Config { git_path: String, ignore_prompts: bool, + allow_repositories: HashSet, } #[derive(Deserialize)] struct FileConfig { @@ -16,6 +20,8 @@ struct FileConfig { git_path: Option, #[serde(default)] ignore_prompts: Option, + #[serde(default)] + allow_repositories: Option>, } static CONFIG: OnceLock = OnceLock::new(); @@ -42,6 +48,25 @@ impl Config { self.ignore_prompts } + pub fn is_allowed_repository(&self, repository: &Option) -> bool { + // If allowlist is empty, allow everything + if self.allow_repositories.is_empty() { + return true; + } + + // If allowlist is defined, only allow repos whose remotes match the list + if let Some(repository) = repository { + match repository.remotes_with_urls().ok() { + Some(remotes) => remotes + .iter() + .any(|remote| self.allow_repositories.contains(&remote.1)), + None => false, // Can't verify, deny by default when allowlist is active + } + } else { + false // No repository provided, deny by default when allowlist is active + } + } + /// Returns whether prompts should be ignored (currently unused by internal APIs). #[allow(dead_code)] pub fn ignore_prompts(&self) -> bool { @@ -55,12 +80,19 @@ fn build_config() -> Config { .as_ref() .and_then(|c| c.ignore_prompts) .unwrap_or(false); + let allow_repositories = file_cfg + .as_ref() + .and_then(|c| c.allow_repositories.clone()) + .unwrap_or(vec![]) + .into_iter() + .collect(); let git_path = resolve_git_path(&file_cfg); Config { git_path, ignore_prompts, + allow_repositories, } } diff --git a/src/git/repository.rs b/src/git/repository.rs index 74fbd9e6..acb87292 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -586,6 +586,33 @@ impl Repository { Ok(remotes.trim().split("\n").map(|s| s.to_string()).collect()) } + // List all remotes with their URLs as tuples (name, url) + pub fn remotes_with_urls(&self) -> Result, GitAiError> { + let mut args = self.global_args_for_exec(); + args.push("remote".to_string()); + args.push("-v".to_string()); + + let output = exec_git(&args)?; + let remotes_output = String::from_utf8(output.stdout)?; + + let mut remotes = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for line in remotes_output.trim().split("\n").filter(|s| !s.is_empty()) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let name = parts[0].to_string(); + let url = parts[1].to_string(); + // Only add each remote once (git remote -v shows fetch and push) + if seen.insert(name.clone()) { + remotes.push((name, url)); + } + } + } + + Ok(remotes) + } + pub fn config_get_str(&self, key: &str) -> Result, GitAiError> { let mut args = self.global_args_for_exec(); args.push("config".to_string());