Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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