Skip to content
8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "gitkit"
version = "0.1.2"
version = "0.2.0"
edition = "2021"
description = "Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattributes"
license = "MIT"
Expand All @@ -16,3 +16,9 @@ path = "src/main.rs"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
ureq = "2"

[dev-dependencies]
tempfile = "3"

[dependencies.inquire]
version = "0.7"
110 changes: 79 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
# gitkit
```
███ █████ █████ ███ █████
░░░ ░░███ ░░███ ░░░ ░░███
███████ ████ ███████ ░███ █████ ████ ███████
███░░███░░███ ░░░███░ ░███░░███ ░░███ ░░░███░
░███ ░███ ░███ ░███ ░██████░ ░███ ░███
░███ ░███ ░███ ░███ ███ ░███░░███ ░███ ░███ ███
░░███████ █████ ░░█████ ████ █████ █████ ░░█████
░░░░░███░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░ ░░░░░
███ ░███
░░██████
░░░░░░
```

[![CI](https://github.com/JheisonMB/gitkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JheisonMB/gitkit/actions/workflows/ci.yml)
[![Release](https://github.com/JheisonMB/gitkit/actions/workflows/release.yml/badge.svg)](https://github.com/JheisonMB/gitkit/actions/workflows/release.yml)
[![Crates.io](https://img.shields.io/crates/v/gitkit)](https://crates.io/crates/gitkit)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattributes. No Node.js, no Python, no runtime dependencies. One binary.
Configure a git repo in seconds — hooks, `.gitignore`, `.gitattributes`, and git config. Interactive wizard or direct commands. No Node.js, no Python, no runtime dependencies. One binary.

---

Expand All @@ -31,61 +43,107 @@ irm https://raw.githubusercontent.com/JheisonMB/gitkit/main/install.ps1 | iex
cargo install gitkit
```

Available on [crates.io](https://crates.io/crates/gitkit).

### GitHub Releases

Check the [Releases](https://github.com/JheisonMB/gitkit/releases) page for precompiled binaries (Linux x86_64, macOS x86_64/ARM64, Windows x86_64).

### Uninstall

**Linux / macOS:**
```bash
rm -f ~/.local/bin/gitkit
```

**Windows (PowerShell):**
```powershell
Remove-Item "$env:LOCALAPPDATA\gitkit\gitkit.exe" -Force
```

---

## Quick Start
## Quick Start

Interactive wizard — guided setup for a new repo:

```bash
gitkit init
```

Or use commands directly:

```bash
# Install a built-in hook
gitkit hooks init commit-msg conventional-commits
gitkit hooks add conventional-commits
gitkit ignore add rust,vscode,agentic
gitkit attributes init
gitkit config apply defaults
```

# Install a custom hook command
gitkit hooks init pre-push "cargo test"
---

# List installed hooks
gitkit hooks list
## `gitkit init`

# Generate a .gitignore
gitkit ignore add rust,vscode
Interactive wizard that guides you through configuring a repo step by step.

- Hooks — built-ins pre-selected, or add a custom command
- `.gitignore` — filterable search across all gitignore.io templates + built-ins
- `.gitattributes` — line endings and binary file presets
- Git config — 6 individual options, recommended ones pre-selected

# Apply line endings preset
gitkit attributes init
```
gitkit init
```

---

## Commands

### Hooks

| Command | Description |
|---|---|
| `gitkit hooks init <hook> <builtin\|command>` | Install a hook (built-in or custom command) |
| `gitkit hooks add <builtin>` | Install a built-in hook (hook name inferred) |
| `gitkit hooks add <hook> <command>` | Install a custom shell command as a hook |
| `gitkit hooks list` | List installed hooks |
| `gitkit hooks remove <hook>` | Remove a hook |
| `gitkit hooks show <hook>` | Show hook content |
| `gitkit ignore add <templates>` | Generate .gitignore via gitignore.io |
| `gitkit hooks list --available` | Show all built-in hooks with descriptions |
| `gitkit hooks remove <hook>` | Remove an installed hook |
| `gitkit hooks show <hook>` | Print hook content |

### Ignore

| Command | Description |
|---|---|
| `gitkit ignore add <templates>` | Generate/merge `.gitignore` via gitignore.io |
| `gitkit ignore list [filter]` | List available templates |
| `gitkit attributes init` | Apply line endings preset |
| `gitkit config apply <preset>` | Apply git config preset (defaults, advanced, delta) |

### Attributes

| Command | Description |
|---|---|
| `gitkit attributes init` | Apply line endings preset to `.gitattributes` |

### Config

| Command | Description |
|---|---|
| `gitkit config apply defaults` | `push.autoSetupRemote`, `help.autocorrect`, `diff.algorithm` |
| `gitkit config apply advanced` | `merge.conflictstyle zdiff3`, `rerere.enabled` |
| `gitkit config apply delta` | `core.pager delta` (requires `cargo`) |

---

## Built-in Hooks

Run `gitkit hooks list --available` to see these without leaving the terminal.

| Name | Hook | Description |
|---|---|---|
| `conventional-commits` | `commit-msg` | Validates Conventional Commits format |
| `no-secrets` | `pre-commit` | Detects common secret patterns |
| `branch-naming` | `pre-commit` | Validates branch name pattern |
| `no-secrets` | `pre-commit` | Detects common secret patterns in staged changes |
| `branch-naming` | `pre-commit` | Validates branch name matches convention |

Built-ins are embedded in the binary — no network required.

---

Expand All @@ -99,16 +157,6 @@ gitkit attributes init

---

## Tech Stack

| Concern | Crate |
|---|---|
| CLI parsing | `clap` (derive) |
| Error handling | `anyhow` |
| HTTP client | `ureq` |

---

## License

MIT
80 changes: 77 additions & 3 deletions src/attributes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,20 @@ use std::fs;

use crate::utils::{confirm, find_repo_root};

const PRESET: &str = "* text=auto eol=lf\n";
const PRESET_LF: &str = "* text=auto eol=lf\n";

const PRESET_BINARY: &str = "\
*.png binary\n\
*.jpg binary\n\
*.jpeg binary\n\
*.gif binary\n\
*.ico binary\n\
*.pdf binary\n\
*.zip binary\n\
*.tar binary\n\
*.gz binary\n\
*.wasm binary\n\
";

#[derive(Subcommand)]
pub enum AttributesCommand {
Expand Down Expand Up @@ -42,11 +55,72 @@ pub fn run(cmd: AttributesCommand) -> Result<()> {
}

if dry_run {
println!("[dry-run] Would write .gitattributes:\n{PRESET}");
println!("[dry-run] Would write .gitattributes:\n{PRESET_LF}");
return Ok(());
}

fs::write(&path, PRESET).context("Failed to write .gitattributes")?;
fs::write(&path, PRESET_LF).context("Failed to write .gitattributes")?;
println!("Applied line endings preset to .gitattributes.");
Ok(())
}

/// Apply one or more attribute presets by label. Used by the interactive wizard.
pub(crate) fn apply_presets(labels: &[&str]) -> Result<()> {
let root = find_repo_root()?;
let path = root.join(".gitattributes");
let existing = if path.exists() {
fs::read_to_string(&path).unwrap_or_default()
} else {
String::new()
};
let mut content = existing;
for label in labels {
let preset = match *label {
"line-endings" => PRESET_LF,
"binary-files" => PRESET_BINARY,
_ => continue,
};
if !content.contains(preset.lines().next().unwrap_or("")) {
if !content.ends_with('\n') && !content.is_empty() {
content.push('\n');
}
content.push_str(preset);
}
}
fs::write(&path, content).context("Failed to write .gitattributes")?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

fn make_git_repo() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
dir
}

#[test]
fn attributes_init_dry_run_does_not_write_file() {
let dir = make_git_repo();
let path = dir.path().join(".gitattributes");
// run with dry_run — file must not be created
// We call the internal logic directly via the public run() with dry_run=true
// but run() calls find_repo_root() which uses CWD, so we test the preset constant
assert_eq!(PRESET_LF, "* text=auto eol=lf\n");
assert!(!path.exists());
}

#[test]
fn attributes_preset_contains_lf_rule() {
assert!(PRESET_LF.contains("eol=lf"));
assert!(PRESET_LF.contains("text=auto"));
}

#[test]
fn attributes_binary_preset_marks_png() {
assert!(PRESET_BINARY.contains("*.png binary"));
}
}
101 changes: 101 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,85 @@ pub fn run(cmd: ConfigCommand) -> Result<()> {
}
}

/// Individual config options exposed for the interactive wizard.
pub(crate) struct ConfigOption {
pub key: &'static str,
pub value: Option<&'static str>, // None for multi-key options like delta
pub label: &'static str,
pub recommended: bool,
}

pub(crate) const CONFIG_OPTIONS: &[ConfigOption] = &[
ConfigOption {
key: "push.autoSetupRemote",
value: Some("true"),
label: "push.autoSetupRemote = true — auto-set upstream on first push",
recommended: true,
},
ConfigOption {
key: "help.autocorrect",
value: Some("prompt"),
label: "help.autocorrect = prompt — suggest corrections for mistyped commands",
recommended: true,
},
ConfigOption {
key: "diff.algorithm",
value: Some("histogram"),
label: "diff.algorithm = histogram — cleaner diffs for moved code",
recommended: true,
},
ConfigOption {
key: "merge.conflictstyle",
value: Some("zdiff3"),
label: "merge.conflictstyle = zdiff3 — show base in conflict markers",
recommended: false,
},
ConfigOption {
key: "rerere.enabled",
value: Some("true"),
label: "rerere.enabled = true — remember and reuse conflict resolutions",
recommended: false,
},
ConfigOption {
key: "core.pager",
value: None, // handled separately — installs git-delta via cargo
label: "core.pager = delta — beautiful syntax-highlighted diffs (requires cargo)",
recommended: false,
},
];

/// Apply selected config option keys. Used by the interactive wizard.
pub(crate) fn apply_config_keys(keys: &[&str], cargo_available: bool) -> Result<()> {
for key in keys {
// Find the matching option to reuse its value from CONFIG_OPTIONS context,
// then dispatch to the appropriate setter.
match *key {
"core.pager" => {
anyhow::ensure!(
cargo_available,
"cargo not found — cannot install git-delta"
);
if !delta_installed() {
install_delta()?;
}
for (k, v) in DELTA_CONFIGS {
git_config_set(k, v)?;
}
}
_ => {
// All non-delta options map directly from CONFIG_OPTIONS value
let value = CONFIG_OPTIONS
.iter()
.find(|o| o.key == *key)
.and_then(|o| o.value)
.ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}"))?;
git_config_set(key, value)?;
}
}
}
Ok(())
}

type GitConfigs = &'static [(&'static str, &'static str)];

const DEFAULTS: GitConfigs = &[
Expand Down Expand Up @@ -138,3 +217,25 @@ fn install_delta() -> Result<()> {
anyhow::ensure!(status.success(), "cargo install git-delta failed");
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn apply_configs_dry_run_prints_without_running_git() {
// dry_run=true must not invoke git; if it did it would fail in CI without a repo
let result = apply_configs(DEFAULTS, true);
assert!(result.is_ok());
}

#[test]
fn apply_configs_dry_run_covers_advanced_preset() {
assert!(apply_configs(ADVANCED, true).is_ok());
}

#[test]
fn apply_configs_dry_run_covers_delta_preset() {
assert!(apply_configs(DELTA_CONFIGS, true).is_ok());
}
}
Loading
Loading