Skip to content

Commit 324ab5c

Browse files
committed
feat: scaffold project with hooks, ignore, attributes, and config subcommands
1 parent 20e7360 commit 324ab5c

9 files changed

Lines changed: 555 additions & 1 deletion

File tree

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,10 @@ Cargo.lock
1111
.kiro/
1212
.agents/
1313
.idea/
14-
skills-lock.json
14+
skills-lock.json
15+
16+
# Added by cargo
17+
#
18+
# already existing elements were commented out
19+
20+
#/target

Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "gitkit"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattributes"
6+
license = "MIT"
7+
repository = "https://github.com/JheisonMB/gitkit"
8+
keywords = ["git", "hooks", "cli", "gitignore", "gitattributes"]
9+
categories = ["command-line-utilities", "development-tools"]
10+
11+
[[bin]]
12+
name = "gitkit"
13+
path = "src/main.rs"
14+
15+
[dependencies]
16+
anyhow = "1"
17+
clap = { version = "4", features = ["derive"] }
18+
ureq = "2"

src/attributes/mod.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use anyhow::{Context, Result};
2+
use clap::Subcommand;
3+
use std::fs;
4+
5+
use crate::utils::{confirm, find_repo_root};
6+
7+
const PRESET: &str = "* text=auto eol=lf\n";
8+
9+
#[derive(Subcommand)]
10+
pub enum AttributesCommand {
11+
/// Apply line endings preset to .gitattributes
12+
Init {
13+
#[arg(short, long)]
14+
yes: bool,
15+
#[arg(short, long)]
16+
force: bool,
17+
#[arg(long)]
18+
dry_run: bool,
19+
},
20+
}
21+
22+
pub fn run(cmd: AttributesCommand) -> Result<()> {
23+
let AttributesCommand::Init {
24+
yes,
25+
force,
26+
dry_run,
27+
} = cmd;
28+
29+
let root = find_repo_root()?;
30+
let path = root.join(".gitattributes");
31+
32+
if path.exists() && !force && !confirm(".gitattributes already exists. Overwrite?", yes) {
33+
println!("Aborted.");
34+
return Ok(());
35+
}
36+
37+
if dry_run {
38+
println!("[dry-run] Would write .gitattributes:\n{PRESET}");
39+
return Ok(());
40+
}
41+
42+
fs::write(&path, PRESET).context("Failed to write .gitattributes")?;
43+
println!("Applied line endings preset to .gitattributes.");
44+
Ok(())
45+
}

src/config/mod.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use anyhow::{Context, Result};
2+
use clap::{Subcommand, ValueEnum};
3+
use std::process::Command;
4+
5+
use crate::utils::confirm;
6+
7+
#[derive(Subcommand)]
8+
pub enum ConfigCommand {
9+
/// Apply a curated git config preset
10+
Apply {
11+
preset: Preset,
12+
#[arg(short, long)]
13+
yes: bool,
14+
#[arg(long)]
15+
dry_run: bool,
16+
},
17+
}
18+
19+
#[derive(ValueEnum, Clone)]
20+
pub enum Preset {
21+
/// push.autoSetupRemote, help.autocorrect, diff.algorithm
22+
Defaults,
23+
/// merge.conflictstyle zdiff3, rerere.enabled (terminal-focused)
24+
Advanced,
25+
/// core.pager delta (installs git-delta if needed)
26+
Delta,
27+
}
28+
29+
pub fn run(cmd: ConfigCommand) -> Result<()> {
30+
let ConfigCommand::Apply {
31+
preset,
32+
yes,
33+
dry_run,
34+
} = cmd;
35+
match preset {
36+
Preset::Defaults => apply_defaults(dry_run),
37+
Preset::Advanced => apply_advanced(dry_run),
38+
Preset::Delta => apply_delta(yes, dry_run),
39+
}
40+
}
41+
42+
type GitConfigs = &'static [(&'static str, &'static str)];
43+
44+
const DEFAULTS: GitConfigs = &[
45+
("push.autoSetupRemote", "true"),
46+
("help.autocorrect", "prompt"),
47+
("diff.algorithm", "histogram"),
48+
];
49+
50+
const ADVANCED: GitConfigs = &[
51+
("merge.conflictstyle", "zdiff3"),
52+
("rerere.enabled", "true"),
53+
];
54+
55+
const DELTA_CONFIGS: GitConfigs = &[
56+
("core.pager", "delta"),
57+
("interactive.diffFilter", "delta --color-only"),
58+
("delta.navigate", "true"),
59+
("delta.side-by-side", "true"),
60+
];
61+
62+
fn apply_defaults(dry_run: bool) -> Result<()> {
63+
apply_configs(DEFAULTS, dry_run)
64+
}
65+
66+
fn apply_advanced(dry_run: bool) -> Result<()> {
67+
println!(
68+
"Warning: merge.conflictstyle=zdiff3 may cause issues with GitHub Desktop and GUI merge tools."
69+
);
70+
apply_configs(ADVANCED, dry_run)
71+
}
72+
73+
fn apply_delta(yes: bool, dry_run: bool) -> Result<()> {
74+
if !delta_installed() {
75+
if !confirm(
76+
"git-delta is not installed. Install via `cargo install git-delta`?",
77+
yes,
78+
) {
79+
println!("Aborted.");
80+
return Ok(());
81+
}
82+
if !dry_run {
83+
install_delta()?;
84+
} else {
85+
println!("[dry-run] Would run: cargo install git-delta");
86+
}
87+
}
88+
println!(
89+
"Note: delta.side-by-side=true may look wrong in narrow terminals. \
90+
Disable with: git config --global delta.side-by-side false"
91+
);
92+
apply_configs(DELTA_CONFIGS, dry_run)
93+
}
94+
95+
fn apply_configs(configs: GitConfigs, dry_run: bool) -> Result<()> {
96+
for (key, value) in configs {
97+
if dry_run {
98+
println!("[dry-run] git config --global {key} {value}");
99+
} else {
100+
git_config_set(key, value)?;
101+
println!("Set {key} = {value}");
102+
}
103+
}
104+
Ok(())
105+
}
106+
107+
fn git_config_set(key: &str, value: &str) -> Result<()> {
108+
let status = Command::new("git")
109+
.args(["config", "--global", key, value])
110+
.status()
111+
.with_context(|| format!("Failed to run git config for '{key}'"))?;
112+
anyhow::ensure!(status.success(), "git config --global {key} {value} failed");
113+
Ok(())
114+
}
115+
116+
fn delta_installed() -> bool {
117+
Command::new("delta")
118+
.arg("--version")
119+
.output()
120+
.map(|o| o.status.success())
121+
.unwrap_or(false)
122+
}
123+
124+
fn install_delta() -> Result<()> {
125+
println!("Installing git-delta...");
126+
let status = Command::new("cargo")
127+
.args(["install", "git-delta"])
128+
.status()
129+
.context("Failed to run cargo install git-delta")?;
130+
anyhow::ensure!(status.success(), "cargo install git-delta failed");
131+
Ok(())
132+
}

src/hooks/builtins.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
pub(super) fn get(name: &str) -> Option<&'static str> {
2+
match name {
3+
"conventional-commits" => Some(CONVENTIONAL_COMMITS),
4+
"no-secrets" => Some(NO_SECRETS),
5+
"branch-naming" => Some(BRANCH_NAMING),
6+
_ => None,
7+
}
8+
}
9+
10+
const CONVENTIONAL_COMMITS: &str = r#"#!/bin/sh
11+
commit_msg=$(cat "$1")
12+
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}'
13+
if ! echo "$commit_msg" | grep -qE "$pattern"; then
14+
echo "ERROR: Commit message does not follow Conventional Commits format."
15+
echo "Expected: <type>(<scope>): <description>"
16+
echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
17+
exit 1
18+
fi
19+
"#;
20+
21+
const NO_SECRETS: &str = r#"#!/bin/sh
22+
# Detects common secret patterns. Not exhaustive — use dedicated tools for production.
23+
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,})'
24+
if git diff --cached --diff-filter=ACM | grep -qE "$patterns"; then
25+
echo "ERROR: Possible secret detected in staged changes."
26+
echo "Review your changes and remove any credentials before committing."
27+
exit 1
28+
fi
29+
"#;
30+
31+
const BRANCH_NAMING: &str = r#"#!/bin/sh
32+
branch=$(git symbolic-ref --short HEAD)
33+
pattern='^(main|master|develop|release/.+|hotfix/.+|feat/.+|fix/.+|chore/.+)$'
34+
if ! echo "$branch" | grep -qE "$pattern"; then
35+
echo "ERROR: Branch name '$branch' does not match naming convention."
36+
echo "Expected pattern: main|master|develop|release/*|hotfix/*|feat/*|fix/*|chore/*"
37+
exit 1
38+
fi
39+
"#;

0 commit comments

Comments
 (0)