diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b25fc3..08b4101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,45 @@ jobs: - name: Cargo test run: cargo test + + publish-check: + name: Publish Check (dry-run) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cargo publish dry-run + run: cargo publish --dry-run + + main-pr-checks: + name: Main PR Requirements + if: github.base_ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check version bump + run: | + CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Version in Cargo.toml: $CURRENT_VERSION" + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + echo "✅ First release (no previous tags)" + else + LAST_VERSION=${LAST_TAG#v} + echo "Last tag version: $LAST_VERSION" + if [ "$CURRENT_VERSION" = "$LAST_VERSION" ]; then + echo "❌ Version in Cargo.toml must be bumped for PRs to main" + exit 1 + fi + echo "✅ Version bumped correctly" + fi diff --git a/.gitignore b/.gitignore index bbfa1f0..b4148c2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,10 @@ Cargo.lock .kiro/ .agents/ .idea/ -skills-lock.json \ No newline at end of file +skills-lock.json + +# Added by cargo +# +# already existing elements were commented out + +#/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..41e1f86 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gitkit" +version = "0.1.0" +edition = "2021" +description = "Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattributes" +license = "MIT" +repository = "https://github.com/JheisonMB/gitkit" +keywords = ["git", "hooks", "cli", "gitignore", "gitattributes"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "gitkit" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +ureq = "2" diff --git a/README.md b/README.md index efd5192..2e7f48b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattribut curl -fsSL https://raw.githubusercontent.com/JheisonMB/gitkit/main/install.sh | sh ``` +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/JheisonMB/gitkit/main/install.ps1 | iex +``` + ### Via cargo ```bash @@ -50,7 +56,7 @@ gitkit hooks init pre-push "cargo test" gitkit hooks list # Generate a .gitignore -gitkit ignore add rust macos +gitkit ignore add rust,vscode # Apply line endings preset gitkit attributes init @@ -67,8 +73,9 @@ gitkit attributes init | `gitkit hooks remove ` | Remove a hook | | `gitkit hooks show ` | Show hook content | | `gitkit ignore add ` | Generate .gitignore via gitignore.io | -| `gitkit ignore list` | List available templates | +| `gitkit ignore list [filter]` | List available templates | | `gitkit attributes init` | Apply line endings preset | +| `gitkit config apply ` | Apply git config preset (defaults, advanced, delta) | --- diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..3df1502 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,75 @@ +# install.ps1 — download and install gitkit on Windows +# Usage: irm https://raw.githubusercontent.com/JheisonMB/gitkit/main/install.ps1 | iex +# +# Options (set as env vars before running): +# $env:VERSION = "0.1.0" # pin a specific version +# $env:INSTALL_DIR = "C:\my\bin" # custom install directory + +$ErrorActionPreference = "Stop" + +$Repo = "JheisonMB/gitkit" +$Binary = "gitkit.exe" +$Target = "x86_64-pc-windows-msvc" +$InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:USERPROFILE\.local\bin" } + +function Info($label, $msg) { + Write-Host " " -NoNewline + Write-Host $label -ForegroundColor Blue -NoNewline + Write-Host " $msg" +} + +function Fail($msg) { + Write-Host " error: $msg" -ForegroundColor Red + exit 1 +} + +# --- resolve version --- +if ($env:VERSION) { + $Tag = "v$($env:VERSION)" + Info "version" "$Tag (pinned)" +} else { + $latest = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" + $Tag = $latest.tag_name + if (-not $Tag) { Fail "Could not resolve latest release tag" } + Info "version" "$Tag (latest)" +} + +# --- download --- +$Archive = "gitkit-$Tag-$Target.zip" +$Url = "https://github.com/$Repo/releases/download/$Tag/$Archive" +$Tmp = Join-Path $env:TEMP "gitkit-install" +New-Item -ItemType Directory -Force -Path $Tmp | Out-Null + +Info "download" $Url +try { + Invoke-WebRequest -Uri $Url -OutFile "$Tmp\$Archive" -UseBasicParsing +} catch { + Fail "Download failed: $_`nURL: $Url" +} + +# --- extract --- +Expand-Archive -Path "$Tmp\$Archive" -DestinationPath $Tmp -Force +$extracted = Join-Path $Tmp $Binary +if (-not (Test-Path $extracted)) { Fail "Binary not found in archive" } + +# --- install --- +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Copy-Item $extracted "$InstallDir\$Binary" -Force +Info "installed" "$InstallDir\$Binary" + +# --- ensure PATH --- +$userPath = [Environment]::GetEnvironmentVariable("PATH", "User") +if ($userPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("PATH", "$InstallDir;$userPath", "User") + $env:PATH = "$InstallDir;$env:PATH" + Info "updated" "User PATH" +} + +# --- cleanup --- +Remove-Item $Tmp -Recurse -Force + +# --- verify --- +$ver = & "$InstallDir\$Binary" --version 2>$null +Info "done" $ver +Write-Host "" +Info "ready" "Run 'gitkit hooks init commit-msg conventional-commits' to get started!" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..15b50ac --- /dev/null +++ b/install.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# install.sh — download and install gitkit from GitHub Releases +# Usage: curl -fsSL https://raw.githubusercontent.com/JheisonMB/gitkit/main/install.sh | sh +set -eu + +REPO="JheisonMB/gitkit" +BINARY="gitkit" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" + +info() { printf ' \033[1;34m%s\033[0m %s\n' "$1" "$2"; } +error() { printf ' \033[1;31merror:\033[0m %s\n' "$1" >&2; exit 1; } + +# --- detect OS --- +OS="$(uname -s)" +case "$OS" in + Linux*) OS_TARGET="unknown-linux-musl" ;; + Darwin*) OS_TARGET="apple-darwin" ;; + *) error "Unsupported OS: $OS (only Linux and macOS are supported)" ;; +esac + +# --- detect arch --- +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH_TARGET="x86_64" ;; + arm64|aarch64) ARCH_TARGET="aarch64" ;; + *) error "Unsupported architecture: $ARCH" ;; +esac + +TARGET="${ARCH_TARGET}-${OS_TARGET}" +info "platform" "$TARGET" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# --- resolve version --- +if [ -n "${VERSION:-}" ]; then + TAG="v$VERSION" + info "version" "$TAG (pinned)" +else + TAG=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" | rev | cut -d'/' -f1 | rev) + [ -z "$TAG" ] && error "Could not resolve latest release tag" + info "version" "$TAG (latest)" +fi + +# --- download --- +ARCHIVE="${BINARY}-${TAG}-${TARGET}.tar.gz" +URL="https://github.com/$REPO/releases/download/${TAG}/${ARCHIVE}" + +info "download" "$URL" +HTTP_CODE=$(curl -fSL -w '%{http_code}' -o "$TMPDIR/$ARCHIVE" "$URL" 2>/dev/null) || true +[ "$HTTP_CODE" = "200" ] || error "Download failed (HTTP $HTTP_CODE). Check that $TAG exists for $TARGET at:\n $URL" + +# --- extract --- +tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" +[ -f "$TMPDIR/$BINARY" ] || error "Binary not found in archive" + +# --- install --- +mkdir -p "$INSTALL_DIR" +mv "$TMPDIR/$BINARY" "$INSTALL_DIR/$BINARY" +chmod +x "$INSTALL_DIR/$BINARY" +info "installed" "$INSTALL_DIR/$BINARY" + +# --- ensure PATH --- +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + export PATH="$INSTALL_DIR:$PATH" + for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + if [ -f "$profile" ]; then + if ! grep -q "export PATH=\"$INSTALL_DIR:\$PATH\"" "$profile" 2>/dev/null; then + printf '\n# Added by gitkit installer\nexport PATH="%s:$PATH"\n' "$INSTALL_DIR" >> "$profile" + info "updated" "$profile" + fi + fi + done + ;; +esac + +# --- verify --- +info "done" "$($INSTALL_DIR/$BINARY --version 2>/dev/null || echo "$BINARY installed")" +echo "" +info "ready" "Run 'gitkit hooks init commit-msg conventional-commits' to get started!" diff --git a/src/attributes/mod.rs b/src/attributes/mod.rs new file mode 100644 index 0000000..13becb1 --- /dev/null +++ b/src/attributes/mod.rs @@ -0,0 +1,52 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::fs; + +use crate::utils::{confirm, find_repo_root}; + +const PRESET: &str = "* text=auto eol=lf\n"; + +#[derive(Subcommand)] +pub enum AttributesCommand { + /// Apply line endings preset to .gitattributes + Init { + #[arg(short, long)] + yes: bool, + #[arg(short, long)] + force: bool, + #[arg(long)] + dry_run: bool, + }, +} + +pub fn run(cmd: AttributesCommand) -> Result<()> { + let AttributesCommand::Init { + yes, + force, + dry_run, + } = cmd; + + let root = find_repo_root()?; + let path = root.join(".gitattributes"); + + if path.exists() && !force { + if !confirm(".gitattributes already exists. Overwrite?", yes) { + println!("Aborted."); + return Ok(()); + } + if !dry_run { + let backup = root.join(".gitattributes.bak"); + std::fs::copy(&path, &backup).context("Failed to backup .gitattributes")?; + println!("Backed up to {}", backup.display()); + } + } + + if dry_run { + println!("[dry-run] Would write .gitattributes:\n{PRESET}"); + return Ok(()); + } + + fs::write(&path, PRESET).context("Failed to write .gitattributes")?; + println!("Applied line endings preset to .gitattributes."); + Ok(()) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..23a7962 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,140 @@ +use anyhow::{Context, Result}; +use clap::{Subcommand, ValueEnum}; +use std::process::Command; + +use crate::utils::confirm; + +#[derive(Subcommand)] +pub enum ConfigCommand { + /// Apply a curated git config preset + Apply { + preset: Preset, + #[arg(short, long)] + yes: bool, + #[arg(long)] + dry_run: bool, + }, +} + +#[derive(ValueEnum, Clone)] +pub enum Preset { + /// push.autoSetupRemote, help.autocorrect, diff.algorithm + Defaults, + /// merge.conflictstyle zdiff3, rerere.enabled (terminal-focused) + Advanced, + /// core.pager delta (installs git-delta if needed) + Delta, +} + +pub fn run(cmd: ConfigCommand) -> Result<()> { + let ConfigCommand::Apply { + preset, + yes, + dry_run, + } = cmd; + match preset { + Preset::Defaults => apply_defaults(dry_run), + Preset::Advanced => apply_advanced(dry_run), + Preset::Delta => apply_delta(yes, dry_run), + } +} + +type GitConfigs = &'static [(&'static str, &'static str)]; + +const DEFAULTS: GitConfigs = &[ + ("push.autoSetupRemote", "true"), + ("help.autocorrect", "prompt"), + ("diff.algorithm", "histogram"), +]; + +const ADVANCED: GitConfigs = &[ + ("merge.conflictstyle", "zdiff3"), + ("rerere.enabled", "true"), +]; + +const DELTA_CONFIGS: GitConfigs = &[ + ("core.pager", "delta"), + ("interactive.diffFilter", "delta --color-only"), + ("delta.navigate", "true"), + ("delta.side-by-side", "true"), +]; + +fn apply_defaults(dry_run: bool) -> Result<()> { + apply_configs(DEFAULTS, dry_run) +} + +fn apply_advanced(dry_run: bool) -> Result<()> { + println!( + "Warning: merge.conflictstyle=zdiff3 may cause issues with GitHub Desktop and GUI merge tools." + ); + apply_configs(ADVANCED, dry_run) +} + +fn apply_delta(yes: bool, dry_run: bool) -> Result<()> { + if !delta_installed() { + if !confirm( + "git-delta is not installed. Install via `cargo install git-delta`?", + yes, + ) { + println!("Aborted."); + return Ok(()); + } + if !dry_run { + install_delta()?; + } else { + println!("[dry-run] Would run: cargo install git-delta"); + } + } + println!( + "Note: delta.side-by-side=true may look wrong in narrow terminals. \ + Disable with: git config --global delta.side-by-side false" + ); + apply_configs(DELTA_CONFIGS, dry_run) +} + +fn apply_configs(configs: GitConfigs, dry_run: bool) -> Result<()> { + for (key, value) in configs { + if dry_run { + println!("[dry-run] git config --global {key} {value}"); + } else { + git_config_set(key, value)?; + println!("Set {key} = {value}"); + } + } + Ok(()) +} + +fn git_config_set(key: &str, value: &str) -> Result<()> { + let status = Command::new("git") + .args(["config", "--global", key, value]) + .status() + .with_context(|| format!("Failed to run git config for '{key}'"))?; + anyhow::ensure!(status.success(), "git config --global {key} {value} failed"); + Ok(()) +} + +fn delta_installed() -> bool { + Command::new("delta") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn install_delta() -> Result<()> { + anyhow::ensure!( + Command::new("cargo") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false), + "cargo not found in PATH — install Rust from https://rustup.rs to install git-delta" + ); + println!("Installing git-delta..."); + let status = Command::new("cargo") + .args(["install", "git-delta"]) + .status() + .context("Failed to run cargo install git-delta")?; + anyhow::ensure!(status.success(), "cargo install git-delta failed"); + Ok(()) +} diff --git a/src/hooks/builtins.rs b/src/hooks/builtins.rs new file mode 100644 index 0000000..674a401 --- /dev/null +++ b/src/hooks/builtins.rs @@ -0,0 +1,39 @@ +pub(super) fn get(name: &str) -> Option<&'static str> { + match name { + "conventional-commits" => Some(CONVENTIONAL_COMMITS), + "no-secrets" => Some(NO_SECRETS), + "branch-naming" => Some(BRANCH_NAMING), + _ => None, + } +} + +const CONVENTIONAL_COMMITS: &str = r#"#!/bin/sh +commit_msg=$(cat "$1") +pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}' +if ! echo "$commit_msg" | grep -qE "$pattern"; then + echo "ERROR: Commit message does not follow Conventional Commits format." + echo "Expected: (): " + echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" + exit 1 +fi +"#; + +const NO_SECRETS: &str = r#"#!/bin/sh +# Detects common secret patterns. Not exhaustive — use dedicated tools for production. +patterns='(AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|ghp_[0-9A-Za-z]{36}|sk-[0-9A-Za-z]{48}|[0-9a-f]{40}|password\s*=\s*["\x27][^"\x27]{8,})' +if git diff --cached --diff-filter=ACM | grep -qE "$patterns"; then + echo "ERROR: Possible secret detected in staged changes." + echo "Review your changes and remove any credentials before committing." + exit 1 +fi +"#; + +const BRANCH_NAMING: &str = r#"#!/bin/sh +branch=$(git symbolic-ref --short HEAD) +pattern='^(main|master|develop|release/.+|hotfix/.+|feat/.+|fix/.+|chore/.+)$' +if ! echo "$branch" | grep -qE "$pattern"; then + echo "ERROR: Branch name '$branch' does not match naming convention." + echo "Expected pattern: main|master|develop|release/*|hotfix/*|feat/*|fix/*|chore/*" + exit 1 +fi +"#; diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 0000000..df682d0 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,178 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::{fs, os::unix::fs::PermissionsExt, path::Path}; + +use crate::utils::{confirm, find_repo_root}; + +mod builtins; + +#[derive(Subcommand)] +pub enum HooksCommand { + /// Install a hook (built-in or custom command) + Init { + /// Git hook name (e.g. commit-msg, pre-push, pre-commit) + hook: String, + /// Built-in name or shell command to run + target: String, + #[arg(short, long)] + yes: bool, + #[arg(short, long)] + force: bool, + #[arg(long)] + dry_run: bool, + }, + /// List installed hooks + List, + /// Remove a hook + Remove { + hook: String, + #[arg(short, long)] + yes: bool, + #[arg(long)] + dry_run: bool, + }, + /// Show hook content + Show { hook: String }, +} + +pub fn run(cmd: HooksCommand) -> Result<()> { + match cmd { + HooksCommand::Init { + hook, + target, + yes, + force, + dry_run, + } => init(&hook, &target, yes, force, dry_run), + HooksCommand::List => list(), + HooksCommand::Remove { hook, yes, dry_run } => remove(&hook, yes, dry_run), + HooksCommand::Show { hook } => show(&hook), + } +} + +fn hooks_dir() -> Result { + Ok(find_repo_root()?.join(".git").join("hooks")) +} + +fn hook_script(target: &str) -> String { + if let Some(script) = builtins::get(target) { + return script.to_owned(); + } + format!("#!/bin/sh\nset -e\n{target}\n") +} + +const VALID_HOOKS: &[&str] = &[ + "applypatch-msg", + "commit-msg", + "fsmonitor-watchman", + "post-update", + "pre-applypatch", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "pre-receive", + "prepare-commit-msg", + "push-to-checkout", + "update", +]; + +fn init(hook: &str, target: &str, yes: bool, force: bool, dry_run: bool) -> Result<()> { + if !VALID_HOOKS.contains(&hook) { + anyhow::bail!( + "'{hook}' is not a valid git hook. Valid hooks: {}", + VALID_HOOKS.join(", ") + ); + } + let dir = hooks_dir()?; + let path = dir.join(hook); + + if path.exists() && !force { + if !confirm(&format!("Hook '{hook}' already exists. Overwrite?"), yes) { + println!("Aborted."); + return Ok(()); + } + if !dry_run { + let backup = dir.join(format!("{hook}.bak")); + fs::copy(&path, &backup).with_context(|| format!("Failed to backup {hook}"))?; + println!("Backed up to {}", backup.display()); + } + } + + let script = hook_script(target); + + if dry_run { + println!("[dry-run] Would write hook '{hook}':\n{script}"); + return Ok(()); + } + + fs::create_dir_all(&dir).context("Failed to create hooks directory")?; + fs::write(&path, &script).with_context(|| format!("Failed to write hook '{hook}'"))?; + set_executable(&path)?; + println!("Installed hook '{hook}'."); + Ok(()) +} + +fn list() -> Result<()> { + let dir = hooks_dir()?; + let hooks: Vec<_> = fs::read_dir(&dir) + .context("Failed to read hooks directory")? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let s = name.to_string_lossy(); + !s.ends_with(".bak") && !s.ends_with(".sample") + }) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + + if hooks.is_empty() { + println!("No hooks installed."); + } else { + for h in hooks { + println!("{h}"); + } + } + Ok(()) +} + +fn remove(hook: &str, yes: bool, dry_run: bool) -> Result<()> { + let path = hooks_dir()?.join(hook); + anyhow::ensure!(path.exists(), "Hook '{hook}' is not installed"); + + if !confirm(&format!("Remove hook '{hook}'?"), yes) { + println!("Aborted."); + return Ok(()); + } + + if dry_run { + println!("[dry-run] Would remove hook '{hook}'."); + return Ok(()); + } + + fs::remove_file(&path).with_context(|| format!("Failed to remove hook '{hook}'"))?; + println!("Removed hook '{hook}'."); + Ok(()) +} + +fn show(hook: &str) -> Result<()> { + let path = hooks_dir()?.join(hook); + anyhow::ensure!(path.exists(), "Hook '{hook}' is not installed"); + let content = + fs::read_to_string(&path).with_context(|| format!("Failed to read hook '{hook}'"))?; + print!("{content}"); + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).context("Failed to set executable permission")?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/src/ignore/mod.rs b/src/ignore/mod.rs new file mode 100644 index 0000000..c07e60c --- /dev/null +++ b/src/ignore/mod.rs @@ -0,0 +1,181 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::fs; + +use crate::utils::find_repo_root; + +const API_BASE: &str = "https://www.toptal.com/developers/gitignore/api"; + +#[derive(Subcommand)] +pub enum IgnoreCommand { + /// Generate .gitignore for the given templates + Add { + /// Comma-separated list of templates (e.g. rust,vscode,agentic) + templates: String, + #[arg(short, long)] + yes: bool, + #[arg(short, long)] + force: bool, + #[arg(long)] + dry_run: bool, + }, + /// List available templates, optionally filtered + List { filter: Option }, +} + +pub fn run(cmd: IgnoreCommand) -> Result<()> { + match cmd { + IgnoreCommand::Add { + templates, + yes, + force, + dry_run, + } => add(&templates, yes, force, dry_run), + IgnoreCommand::List { filter } => list(filter.as_deref()), + } +} + +fn add(templates: &str, _yes: bool, force: bool, dry_run: bool) -> Result<()> { + let root = find_repo_root()?; + let path = root.join(".gitignore"); + + let new_content = resolve_templates(templates)?; + let merged = if force { + new_content.clone() + } else { + merge_gitignore(&path, &new_content) + }; + + if dry_run { + println!("[dry-run] Would write .gitignore:\n{merged}"); + return Ok(()); + } + + fs::write(&path, merged).context("Failed to write .gitignore")?; + println!("Updated .gitignore for: {templates}"); + Ok(()) +} + +/// Split templates, resolve built-ins locally, fetch the rest from the API. +/// Combines both into a single output. +fn resolve_templates(templates: &str) -> Result { + let mut builtin_parts: Vec<&str> = Vec::new(); + let mut api_templates: Vec<&str> = Vec::new(); + + for t in templates.split(',').map(str::trim) { + if builtins::get(t).is_some() { + builtin_parts.push(t); + } else { + api_templates.push(t); + } + } + + let mut output = String::new(); + + for name in &builtin_parts { + output.push_str(builtins::get(name).unwrap()); + } + + if !api_templates.is_empty() { + let joined = api_templates.join(","); + let url = format!("{API_BASE}/{joined}"); + let fetched = ureq::get(&url) + .call() + .context("Failed to fetch gitignore templates")? + .into_string() + .context("Failed to read response")?; + if fetched.trim().is_empty() { + anyhow::bail!( + "No templates found for: {}. Run 'gitkit ignore list' to see available templates.", + joined + ); + } + output.push_str(&fetched); + } + + Ok(output) +} + +fn list(filter: Option<&str>) -> Result<()> { + // Always show built-ins first + for name in builtins::NAMES { + if filter.is_none_or(|f| name.contains(f)) { + println!("{name} (built-in)"); + } + } + + let url = format!("{API_BASE}/list?format=lines"); + let content = ureq::get(&url) + .call() + .context("Failed to fetch template list")? + .into_string() + .context("Failed to read response")?; + + for line in content.lines() { + if filter.is_none_or(|f| line.contains(f)) { + println!("{line}"); + } + } + Ok(()) +} + +/// Merge new gitignore content into existing file, skipping lines already present. +/// Preserves existing content and appends only new non-duplicate lines. +fn merge_gitignore(path: &std::path::Path, new_content: &str) -> String { + let existing = if path.exists() { + fs::read_to_string(path).unwrap_or_default() + } else { + String::new() + }; + + let existing_lines: std::collections::HashSet<&str> = existing.lines().collect(); + + let to_append: String = new_content + .lines() + .filter(|line| !existing_lines.contains(line)) + .fold(String::new(), |mut acc, line| { + acc.push_str(line); + acc.push('\n'); + acc + }); + + if to_append.trim().is_empty() { + return existing; + } + + let mut result = existing; + if !result.ends_with('\n') && !result.is_empty() { + result.push('\n'); + } + result.push_str(&to_append); + result +} + +mod builtins { + pub(super) const NAMES: &[&str] = &["agentic"]; + + pub(super) fn get(name: &str) -> Option<&'static str> { + match name { + "agentic" => Some(AGENTIC), + _ => None, + } + } + + const AGENTIC: &str = "\ +# Kiro +.kiro/ +skills-lock.json + +# Agent specs / project context +.agents/ + +# Cursor +.cursor/ + +# GitHub Copilot +.copilot/ + +# Continue +.continue/ +"; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9d96c3f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; + +mod attributes; +mod config; +mod hooks; +mod ignore; +mod utils; + +#[derive(Parser)] +#[command( + name = "gitkit", + version, + about = "Standalone CLI for configuring git repos" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Manage git hooks + Hooks { + #[command(subcommand)] + action: hooks::HooksCommand, + }, + /// Generate .gitignore via gitignore.io + Ignore { + #[command(subcommand)] + action: ignore::IgnoreCommand, + }, + /// Configure .gitattributes + Attributes { + #[command(subcommand)] + action: attributes::AttributesCommand, + }, + /// Apply curated git config presets + Config { + #[command(subcommand)] + action: config::ConfigCommand, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Hooks { action } => hooks::run(action), + Command::Ignore { action } => ignore::run(action), + Command::Attributes { action } => attributes::run(action), + Command::Config { action } => config::run(action), + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..2d0db02 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,27 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; + +/// Walk up from CWD until we find a `.git` directory, like git itself does. +pub(crate) fn find_repo_root() -> Result { + let mut dir = std::env::current_dir().context("Failed to get current directory")?; + loop { + if dir.join(".git").exists() { + return Ok(dir); + } + let Some(parent) = dir.parent() else { + anyhow::bail!("Not inside a git repository"); + }; + dir = parent.to_path_buf(); + } +} + +/// Prompt the user for confirmation. Returns true if --yes or user types y/Y. +pub(crate) fn confirm(prompt: &str, yes: bool) -> bool { + if yes { + return true; + } + eprint!("{} [y/N] ", prompt); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap_or(0); + matches!(input.trim(), "y" | "Y") +}