diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fe01c3..522d961 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,100 +2,8 @@ name: CI
on:
pull_request:
- branches:
- - develop
- - main
-
-permissions:
- contents: read
-
-env:
- CARGO_TERM_COLOR: always
+ workflow_dispatch:
jobs:
- check:
- name: Check, Test & Clippy
- 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: Cache cargo index
- uses: actions/cache@v4
- with:
- path: ~/.cargo/git
- key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
-
- - name: Cache cargo build
- uses: actions/cache@v4
- with:
- path: target
- key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
-
- - name: Cargo fmt
- run: cargo fmt --check
-
- - name: Cargo build
- run: cargo build --release
-
- - name: Cargo test
- run: cargo test
-
- - name: Cargo clippy
- run: cargo clippy --all-targets -- -D warnings
-
- 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: Cache cargo index
- uses: actions/cache@v4
- with:
- path: ~/.cargo/git
- key: ${{ runner.os }}-cargo-index-${{ 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
+ rust-ci:
+ uses: UniverLab/workflows/.github/workflows/rust-ci.yml@main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 478b934..ef3cfea 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,218 +2,18 @@ name: Release
on:
pull_request:
- branches:
- - main
- types:
- - closed
- paths:
- - Cargo.toml
+ branches: [main]
+ types: [closed]
+ paths: [Cargo.toml]
workflow_dispatch:
permissions:
contents: write
-env:
- CARGO_TERM_COLOR: always
-
jobs:
- create-tag:
- name: Create Release Tag
+ release:
if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
- runs-on: ubuntu-latest
- outputs:
- tag: ${{ steps.version.outputs.tag }}
- version: ${{ steps.version.outputs.version }}
- created: ${{ steps.create.outputs.created }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Get version from Cargo.toml
- id: version
- run: |
- VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/' | tr -d '\r' | xargs)
- TAG="v${VERSION}"
- echo "version=${VERSION}" >> $GITHUB_OUTPUT
- echo "tag=${TAG}" >> $GITHUB_OUTPUT
-
- - name: Check if tag already exists
- id: check_tag
- run: |
- TAG=${{ steps.version.outputs.tag }}
- if git rev-parse "$TAG" >/dev/null 2>&1; then
- echo "exists=true" >> $GITHUB_OUTPUT
- echo "Tag $TAG already exists, skipping"
- else
- echo "exists=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Create and push tag
- id: create
- run: |
- if [ "${{ steps.check_tag.outputs.exists }}" = "true" ]; then
- echo "created=false" >> $GITHUB_OUTPUT
- echo "Tag already exists — skipping release"
- exit 0
- fi
- TAG=${{ steps.version.outputs.tag }}
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -a "$TAG" -m "Release $TAG"
- git push origin "$TAG"
- echo "created=true" >> $GITHUB_OUTPUT
- echo "Tag $TAG created and pushed"
-
- build:
- name: Build ${{ matrix.target }}
- needs: create-tag
- if: needs.create-tag.outputs.created == 'true'
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- include:
- - target: x86_64-unknown-linux-musl
- os: ubuntu-latest
- archive: tar.gz
- - target: aarch64-apple-darwin
- os: macos-latest
- archive: tar.gz
- - target: x86_64-apple-darwin
- os: macos-latest
- archive: tar.gz
- - target: x86_64-pc-windows-msvc
- os: windows-latest
- archive: zip
-
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.create-tag.outputs.tag }}
-
- - name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
- with:
- targets: ${{ matrix.target }}
-
- - name: Install musl tools (Linux)
- if: matrix.target == 'x86_64-unknown-linux-musl'
- run: sudo apt-get update && sudo apt-get install -y musl-tools
-
- - name: Build release binary
- run: cargo build --release --target ${{ matrix.target }}
-
- - name: Package (unix)
- if: matrix.archive == 'tar.gz'
- run: |
- cd target/${{ matrix.target }}/release
- tar czf "../../../gitkit-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.tar.gz" gitkit
- cd ../../..
-
- - name: Package (windows)
- if: matrix.archive == 'zip'
- shell: pwsh
- run: |
- cd target/${{ matrix.target }}/release
- Compress-Archive -Path gitkit.exe -DestinationPath "../../../gitkit-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.zip"
- cd ../../..
-
- - name: Upload artifact
- uses: actions/upload-artifact@v4
- with:
- name: gitkit-${{ matrix.target }}
- path: gitkit-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.*
-
- github-release:
- name: Create GitHub Release
- needs: [create-tag, build]
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Download all artifacts
- uses: actions/download-artifact@v4
- with:
- path: artifacts
- merge-multiple: true
-
- - name: Create release
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{ needs.create-tag.outputs.tag }}
- generate_release_notes: true
- files: artifacts/*
-
- approve:
- name: Awaiting Manual Approval
- needs: github-release
- runs-on: ubuntu-latest
- environment:
- name: production
- steps:
- - name: Approval granted
- run: echo "Release approved for publishing to crates.io"
-
- ensure-owners:
- name: Ensure crates.io owners
- needs: create-tag
- if: needs.create-tag.outputs.created == 'true'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.create-tag.outputs.tag }}
- - name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
-
- - name: Add crates.io owners (best-effort)
- env:
- TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- OWNERS: github:univerlab:owners
- run: |
- set -eu
- if [ -z "$TOKEN" ]; then
- echo "No cargo token found in CARGO_REGISTRY_TOKEN; skipping owners update"
- exit 0
- fi
- CRATE_NAME=$(grep '^name = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/' | tr -d '\r')
- echo "crate=$CRATE_NAME"
- for owner in $(echo "$OWNERS" | tr ',' ' '); do
- echo "Adding owner: $owner"
- cargo owner --token "$TOKEN" --add "$owner" || echo "cargo owner failed for $owner (continuing)"
- done
-
- publish:
- name: Publish to crates.io
- needs: [create-tag, approve, ensure-owners]
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.create-tag.outputs.tag }}
-
- - name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
-
- - name: Check if stable release
- id: version_check
- run: |
- VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/' | xargs)
- if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "is_stable=true" >> $GITHUB_OUTPUT
- echo "Version $VERSION is stable — will publish"
- else
- echo "is_stable=false" >> $GITHUB_OUTPUT
- echo "Version $VERSION is prerelease/dev — skipping publish"
- fi
-
- - name: Cargo publish
- if: steps.version_check.outputs.is_stable == 'true'
- env:
- CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- run: cargo publish --allow-dirty
-
- - name: Skipped prerelease
- if: steps.version_check.outputs.is_stable == 'false'
- run: echo "⏭️ Skipped crates.io publish for prerelease version"
+ uses: UniverLab/workflows/.github/workflows/rust-release.yml@main
+ with:
+ binary-name: gitkit
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index b4148c2..fd93d04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,5 @@ skills-lock.json
# already existing elements were commented out
#/target
+*.mp4
+.mimocode/
diff --git a/Cargo.toml b/Cargo.toml
index bb96392..980011c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,8 @@ path = "src/main.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
+serde = { version = "1", features = ["derive"] }
+toml = "0.8"
ureq = "2"
[dev-dependencies]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f50fef1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Jheison Morales
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 8d32d47..758976d 100644
--- a/README.md
+++ b/README.md
@@ -12,10 +12,12 @@
░░░░░░
```
-[](https://github.com/UniverLab/gitkit/actions/workflows/ci.yml)
-[](https://github.com/UniverLab/gitkit/actions/workflows/release.yml)
-[](https://crates.io/crates/gitkit)
-[](LICENSE)
+
+
+
+
+
+
Set up a git repo the way you actually work — one guided flow for hooks, `.gitignore`, `.gitattributes`, and git config. One binary, no Node.js, no Python, no runtime dependencies.
@@ -30,10 +32,12 @@ Set up a git repo the way you actually work — one guided flow for hooks, `.git
## Features
- **🪄 Guided repo setup** — Configure hooks, `.gitignore`, `.gitattributes`, and git config in one interactive flow.
+- **📊 Status overview** — See what's currently configured with `gitkit status`.
- **🔁 Clone and bootstrap** — Clone a repo and drop straight into the setup wizard.
- **🧰 Hook management** — Install, list, show, or remove built-in hooks, or wire up your own command.
- **🧩 Ignore and attribute presets** — Browse built-in and gitignore.io templates, then apply line-ending or binary presets.
-- **⚙️ Curated git config** — Apply practical presets like auto-upstream, autocorrect, histogram diffs, zdiff3, rerere, and delta pager setup.
+- **⚙️ Curated git config** — Apply practical presets with `--global` or `--local` scope, with idempotency detection.
+- **💾 Save & reuse builds** — Save configurations and apply them to any project with one command.
- **📦 Single binary** — No Node.js, no Python, no extra runtime.
---
@@ -82,18 +86,24 @@ Remove-Item "$env:LOCALAPPDATA\gitkit\gitkit.exe" -Force
## Quick Start
-**Clone and configure a repo in one command:**
+**Run the wizard (no arguments needed):**
```bash
-gitkit clone https://github.com/user/repo
+gitkit
```
-Or configure an existing repo:
+Or explicitly:
```bash
gitkit init
```
+**Clone and configure a repo in one command:**
+
+```bash
+gitkit clone https://github.com/user/repo
+```
+
Or use commands directly:
```bash
@@ -103,23 +113,65 @@ gitkit attributes init
gitkit config apply defaults
```
+## Documentation
+
+Full documentation lives in [`docs/`](docs/): installation, quick start,
+hooks, ignore & attributes, config presets, builds and the complete CLI reference.
+
+---
+
+## `gitkit status`
+
+Show what's currently configured in your repo and globally.
+
+```bash
+gitkit status
+```
+
+**Output example:**
+
+```
+Hooks:
+ ✓ conventional-commits (commit-msg)
+ ✓ custom: pre-push → "cargo test"
+
+.gitignore:
+ ✓ 14 patterns
+
+.gitattributes:
+ ✓ line-endings (eol=lf)
+
+Git config (local):
+ (none)
+
+Git config (global):
+ ✓ push.autoSetupRemote = true
+ ✓ help.autocorrect = prompt
+ ✓ diff.algorithm = histogram
+```
+
---
## `gitkit init`
-Interactive wizard that guides you through configuring a repo step by step.
+Interactive wizard that guides you through configuring a repo step by step. Shows what's already configured and allows removal.
-- Hooks — built-ins pre-selected, or add a custom command
+- Hooks — shows installed hooks, pre-selects them, allows removal
- `.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
+- Git config — shows current values, allows removal
+- Custom hooks — interactive picker for hook type selection
-Automatically initializes a git repository if one doesn't exist:
+Run without arguments or explicitly:
```bash
+gitkit
+# or
gitkit init
```
+Automatically initializes a git repository if one doesn't exist.
+
---
## `gitkit clone`
@@ -192,6 +244,50 @@ The wizard runs automatically after cloning, allowing you to configure hooks, `.
| `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`) |
+| `gitkit config show` | Show current git config values |
+
+**Scope options:**
+
+- `--global` — Apply to global git config (all repos)
+- `--local` — Apply to local repo config only
+- Default: `--local` if in a repo, `--global` otherwise
+
+**Idempotency:**
+
+Configs already set with the same value show `(already set)` and are skipped.
+
+```bash
+$ gitkit config apply defaults --global
+✓ push.autoSetupRemote = true (already set)
+✓ help.autocorrect = prompt (already set)
+✓ diff.algorithm = histogram (already set)
+
+All configs already applied.
+```
+
+### Build
+
+Save and reuse configurations across projects.
+
+| Command | Description |
+|---|---|
+| `gitkit build list` | List saved builds |
+| `gitkit build save ` | Save current repo config as a build |
+| `gitkit build apply ` | Apply a saved build |
+| `gitkit build delete ` | Delete a saved build |
+
+**Example:**
+
+```bash
+# Save current configuration
+gitkit build save rust-dev --description "Rust development setup"
+
+# Apply to another project
+cd /path/to/other/project
+gitkit build apply rust-dev
+```
+
+Builds are saved to `~/.gitkit/builds/` as TOML files.
---
@@ -223,13 +319,7 @@ Built-ins are embedded in the binary — no network required.
MIT
----
-## Support
-
-- 📖 [GitHub Issues](https://github.com/UniverLab/gitkit/issues) — Report bugs or request features
-- 💬 [Discussions](https://github.com/UniverLab/gitkit/discussions) — Ask questions
-- 🐦 Twitter: [@JheisonMB](https://twitter.com/JheisonMB)
-
---
-Made with ❤️ by [JheisonMB](https://github.com/JheisonMB) and [UniverLab](https://github.com/UniverLab)
+An experiment of [UniverLab](https://github.com/UniverLab) — an open computational laboratory.
+Made with ❤️ by [JheisonMB](https://github.com/JheisonMB)
diff --git a/assets/demo.gif b/assets/demo.gif
index 7a82971..004babd 100644
Binary files a/assets/demo.gif and b/assets/demo.gif differ
diff --git a/demo/demo.rec b/demo/demo.rec
new file mode 100644
index 0000000..d9cb977
--- /dev/null
+++ b/demo/demo.rec
@@ -0,0 +1,339 @@
+{"demostage":{"captions":[],"demo":{"name":"demo","output_dir":"./dist","prompt":"\\[\\e[1;32m\\]gitkit@univerlab\\[\\e[0m\\]:\\[\\e[1;34m\\]~\\[\\e[0m\\]$ "},"duration":25.248482625999998,"faithful":false,"focuses":[[0.121114331,"main"]],"layout":{"background":"#0b0f14","font_family":"IBM Plex Mono","fps":15,"height":1080,"line_height":1.2000000476837158,"panes":[{"font_family":"monospace","font_size":16,"height":1080,"id":"main","type":"terminal","width":1440,"x":0,"y":0}],"width":1440},"timeline":[{"action":"focus","pane":"main"},{"action":"type","human_salt":true,"text":"gitkit clone https://github.com/Univerlab/gitkit.git"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_quiet","quiet_ms":1500},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":349},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":1316},{"action":"type","human_salt":true,"text":" "},{"action":"wait","duration_ms":2295},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":2500},{"action":"type","human_salt":true,"text":" "},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_quiet","quiet_ms":500},{"action":"type","human_salt":true,"text":" "},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_quiet","quiet_ms":1500},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":180},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":180},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":174},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":183},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":452},{"action":"type","human_salt":true,"text":" "},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_quiet","quiet_ms":1500},{"action":"type","human_salt":true,"text":"y"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_quiet","quiet_ms":1500},{"action":"type","human_salt":true,"text":"n"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_quiet","quiet_ms":1500},{"action":"terminate"}],"typing":{"base_ms":80,"salt_ms":15}},"env":{"TERM":"xterm-256color"},"height":54,"title":"demo","version":2,"width":144}
+[0.000149573,"o","\r\n\u001b[?2004l\r"]
+[0.000341838,"o","\u001b]697;OSCUnlock=e7d40ddbbcf646adb2a3f991e2653752\u0007\u001b]697;Dir=/home/jheisonmblivecom\u0007"]
+[0.000395938,"o","\u001b]697;Shell=bash\u0007\u001b]697;ShellPath=/usr/bin/bash\u0007"]
+[0.000432054,"o","\u001b]697;WSLDistro=Ubuntu\u0007\u001b]697;PID=384284\u0007"]
+[0.000454295,"o","\u001b]697;ExitCode=0\u0007"]
+[0.000503406,"o","\u001b]697;TTY=/dev/pts/3\u0007\u001b]697;Log=\u0007"]
+[0.000550283,"o","\u001b]697;User=jheisonmblivecom\u0007"]
+[0.002205377,"o","\u001b]697;OSCLock=e7d40ddbbcf646adb2a3f991e2653752\u0007\u001b]697;PreExec\u0007"]
+[0.090440045,"o","\u001b[?2004h\u001b]697;StartPrompt\u0007\u001b[1;32mgitkit@univerlab\u001b[0m:\u001b[1;34m~\u001b[0m$ \u001b]697;EndPrompt\u0007\u001b]697;NewCmd=e7d40ddbbcf646adb2a3f991e2653752\u0007"]
+[0.205396331,"o","g"]
+[0.29245563,"o","i"]
+[0.374586576,"o","t"]
+[0.456665947,"o","k"]
+[0.542812294,"o","i"]
+[0.616967685,"o","t"]
+[0.708014315,"o"," "]
+[0.78715641,"o","c"]
+[0.876269577,"o","l"]
+[0.954341305,"o","o"]
+[1.044411149,"o","n"]
+[1.121504251,"o","e"]
+[1.21558598,"o"," "]
+[1.304678009,"o","h"]
+[1.371741417,"o","t"]
+[1.4529130160000001,"o","t"]
+[1.51900404,"o","p"]
+[1.584327246,"o","s"]
+[1.665471969,"o",":"]
+[1.737550897,"o","/"]
+[1.816689335,"o","/"]
+[1.8877970039999998,"o","g"]
+[1.97192102,"o","i"]
+[2.049988074,"o","t"]
+[2.145095254,"o","h"]
+[2.213184113,"o","u"]
+[2.306281667,"o","b"]
+[2.371359412,"o","."]
+[2.4474508090000002,"o","c"]
+[2.537576432,"o","o"]
+[2.62266493,"o","m"]
+[2.6917531649999997,"o","/"]
+[2.761850503,"o","U"]
+[2.846986783,"o","n"]
+[2.9410971139999997,"o","i"]
+[3.006182029,"o","v"]
+[3.08729468,"o","e"]
+[3.167392549,"o","r"]
+[3.259514481,"o","l"]
+[3.337584911,"o","a"]
+[3.407682461,"o","b"]
+[3.48777246,"o","/"]
+[3.5718810469999998,"o","g"]
+[3.654973113,"o","i"]
+[3.73307455,"o","t"]
+[3.818157087,"o","k"]
+[3.910342722,"o","i"]
+[3.998390311,"o","t"]
+[4.076527235,"o","."]
+[4.153620093,"o","g"]
+[4.221737178,"o","i"]
+[4.31486321,"o","t"]
+[4.516573471,"o","\r\n\u001b[?2004l\r"]
+[4.518526525,"o","\r\n ◇ cloning repository...\r\n"]
+[4.778916875,"o","\r\n"]
+[6.3458408760000005,"o","^[[B"]
+[6.545744518,"o"," ◇ clone completed ✓\r\n\r\n ◇ running gitkit init..."]
+[6.545750649,"o","\r\n"]
+[6.545752102,"o","\r\n"]
+[6.545808827,"o","\r\n ███ █████ █████ ███ █████ \r\n ░░░ ░░███ ░░███ ░░░ ░░███ \r\n ███████ ████ ███████ ░███ █████ ████ ███████ \r\n ███░░███░░███ ░░░███░ ░███░░███ ░░███ ░░░███░ \r\n░███ ░███ ░███ ░███ ░██████░ ░███ ░███ \r\n░███ ░███ ░███ ░███ ███ ░███░░███ ░███ ░███ ███\r\n░░███████ █████ ░░█████ ████ █████ █████ ░░█████ \r\n ░░░░░███░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░ ░░░░░ \r\n ███ ░███ \r\n░░██████ \r\n ░░░░░░ \r\n\r\n Configure your git repo"]
+[6.545825247,"o","\r\n\r\n"]
+[6.545958405,"o","\u001b[?25l\u001b[38;"]
+[6.545974545,"o","5;10m"]
+[6.545976278,"o","?\u001b["]
+[6.546008636,"o","39m Saved builds available"]
+[6.546024385,"o"," \r"]
+[6.546026018,"o","\n"]
+[6.546090539,"o","\u001b[38;5;14m>\u001b[39m \u001b[38;5;14m"]
+[6.546140469,"o","Start fresh configuration\u001b[39m\r\n Use build: rust\r\n\u001b[38;5;14m[\u001b["]
+[6.54615706,"o","39"]
+[6.546158633,"o","m"]
+[6.546169122,"o","\u001b[38;5;14"]
+[6.546207593,"o","m↑↓ move enter confirm esc start fresh\u001b["]
+[6.546210037,"o","39"]
+[6.546227279,"o","m"]
+[6.546233421,"o","\u001b[38;5;14"]
+[6.546274236,"o","m]\u001b[39m\r\u001b[3"]
+[6.546291188,"o","A\u001b[25"]
+[6.54631933,"o","C\u001b[?25h"]
+[6.546398696,"o","\u001b[?25l\u001b[25D\u001b[38;"]
+[6.546415026,"o","5;10m>\u001b["]
+[6.546425335,"o","39m "]
+[6.546426918,"o","Saved builds available"]
+[6.5464646680000005,"o"," \u001b[38;5;14mStart fresh configuration\u001b[39m"]
+[6.546478824,"o","\u001b[K\r"]
+[6.546485165,"o","\n\u001b[2K\r"]
+[6.546526332,"o","\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h"]
+[6.546529057,"o","\u001b["]
+[6.546578959,"o","3A\u001b[?25h\r\n"]
+[6.5584489040000005,"o","\u001b[?25l\u001b[38;5;10m"]
+[6.558466837,"o","?\u001b["]
+[6.558485932,"o","39"]
+[6.558504677,"o","m Hooks "]
+[6.558515968,"o"," \r"]
+[6.558517471,"o","\n\u001b[38;"]
+[6.558526688,"o","5;14m"]
+[6.558540513,"o",">"]
+[6.558599425,"o","\u001b[39m \u001b["]
+[6.558616176,"o","38;5;14m"]
+[6.5586745010000005,"o","[x]\u001b[39m \u001b["]
+[6.558691742,"o","38;5;14"]
+[6.558693646,"o","mconventional-commits (commit-msg) — Validates Conventional Commits format"]
+[6.558699867,"o","\u001b[39"]
+[6.558715106,"o","m"]
+[6.558740703,"o","\r\n "]
+[6.5587622020000005,"o","[ ]"]
+[6.5587716,"o"," no-secrets (pre-commit) — Detects common secret patterns in staged changes\r\n"]
+[6.558783572,"o"," [ ] "]
+[6.558801435,"o","branch-naming (pre-commit) — Validates branch name matches convention"]
+[6.558818887,"o","\r"]
+[6.558840908,"o","\n"]
+[6.558842411,"o"," "]
+[6.55889095,"o","[ ] Add custom hook...\r"]
+[6.558911248,"o","\n\u001b[38;5;14"]
+[6.55892359,"o","m[\u001b[39m"]
+[6.558942986,"o","\u001b[38;5;14"]
+[6.558972881,"o","m↑↓ move space select enter confirm esc skip\u001b["]
+[6.558986086,"o","39"]
+[6.559019548,"o","m\u001b[38;5;14m]\u001b[39m\r\u001b[5A\u001b["]
+[6.559068261,"o","8C\u001b[?25h"]
+[6.559147336,"o","\u001b[?25l\u001b[8D\r\n \u001b[38;5;10"]
+[6.559151564,"o","m"]
+[6.559167062,"o","[x]\u001b[39"]
+[6.559226359,"o","m conventional-commits (commit-msg) — Validates Conventional Commits format\u001b[K\r\n\u001b[38;5;14m>\u001b[39m "]
+[6.559245104,"o","\u001b[38;5;14m[ ]"]
+[6.559257707,"o","\u001b[39m \u001b["]
+[6.559269589,"o","38;5;14"]
+[6.55928091,"o","m"]
+[6.559303883,"o","no-secrets (pre-commit) — Detects common secret patterns in staged changes\u001b[39m"]
+[6.559326374,"o","\u001b[K"]
+[6.559327937,"o","\r\n"]
+[6.559337004,"o","\r\n"]
+[6.559350939,"o","\r"]
+[6.559372129,"o","\n"]
+[6.559373641,"o","\r\u001b["]
+[6.5594073139999995,"o","5A\u001b[8"]
+[6.559425908,"o","C\u001b[?25h"]
+[6.759725409,"o","\u001b[?25l\u001b[8D\r\n\r\n [ ] no-secrets (pre-commit) — Detects common secret patterns in staged changes\u001b[K\r\n\u001b[38;5;14m>"]
+[6.759731019,"o","\u001b[39m \u001b["]
+[6.759776111,"o","38;5;14m[ ]\u001b[39m \u001b[38;5;14mbranch-naming (pre-commit) — Validates branch name matches convention\u001b[39m\u001b[K\r\n\r\n"]
+[6.759780139,"o","\r\u001b[5A"]
+[6.759830219,"o","\u001b[8C\u001b[?25h"]
+[8.236640277,"o","\u001b[?25l\u001b[8D\r\n\r\n\r\n\u001b[38;5;14m>\u001b[39m \u001b["]
+[8.236656307,"o","38;5;14m[x]\u001b[39m"]
+[8.236721216,"o"," \u001b[38;5;14mbranch-naming (pre-commit) — Validates branch name matches convention\u001b[39m\u001b[K\r\n"]
+[8.236724322,"o","\r\n"]
+[8.236771038,"o","\r\u001b[5A\u001b[8C\u001b[?25h"]
+[10.536333619,"o","\u001b[?25l\u001b[8D\r\n\r\n\r\n \u001b[38;5;10m[x]\u001b[39m branch-naming (pre-commit) — Validates branch name matches convention\u001b[K\r\n\u001b[38;5;14m>\u001b[39m \u001b[38;5;14m[ ]\u001b[39m \u001b[38;5;14mAdd custom hook...\u001b[39m\u001b[K\r\n\r\u001b[5A\u001b[8C\u001b[?25h"]
+[13.189113385,"o","\u001b[?25l\u001b[8D\r\n\r\n\r\n"]
+[13.189162055,"o","\r\n\u001b[38;5;14m>"]
+[13.189188965,"o","\u001b[39m"]
+[13.189190888,"o"," \u001b[38;5;14"]
+[13.189192141,"o","m"]
+[13.189241435,"o","[x]\u001b[39m \u001b[38;5;14mAdd custom hook...\u001b[39m\u001b[K\r\n"]
+[13.189246013,"o","\r\u001b[5A\u001b[8C\u001b[?25h"]
+[13.271271766,"o","\u001b[?25l\u001b[8D"]
+[13.271276635,"o","\r\n\r"]
+[13.271287104,"o","\n\r\n"]
+[13.271288828,"o","\r\n"]
+[13.271306771,"o","\u001b[38;5;14m"]
+[13.271326808,"o",">\u001b[39"]
+[13.27136025,"o","m \u001b[38;5;14m[ ]\u001b[39m \u001b[38;5;14mAdd custom hook...\u001b[39"]
+[13.271371901,"o","m\u001b[K\r\n\r\u001b["]
+[13.271373394,"o","5"]
+[13.271380848,"o","A\u001b[8"]
+[13.271410973,"o","C\u001b[?25h"]
+[13.472967083,"o","\u001b[?25l\u001b[8D\u001b[38;5;10"]
+[13.472971772,"o","m>\u001b[39m Hooks "]
+[13.473011108,"o","\u001b[38;5;14mconventional-commits (commit-msg) — Validates Conventional Commits format, branch-naming (pre-commit) — Validates b\u001b[39m\u001b[K\r\n\u001b[38;5;14mranch name matches convention\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K"]
+[13.473048246,"o","\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h\u001b["]
+[13.473091867,"o","4A\u001b[?25h"]
+[13.473128321,"o","\r\n"]
+[13.74868024,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m .gitignore templates \r\n\u001b[38;5;14m"]
+[13.748686071,"o",">\u001b[39m \u001b[38;5;14m[ ]"]
+[13.748688536,"o","\u001b[39"]
+[13.74869628,"o","m \u001b[38;"]
+[13.748726386,"o","5;14"]
+[13.74872854,"o","magentic"]
+[13.748769064,"o","\u001b[39m\r\n [ ] 1c\r\n [ ]"]
+[13.748771399,"o"," 1c-bitrix\r\n [ ]"]
+[13.748831818,"o"," a-frame\r\n [ ] actionscript\r\n [ ] ada\r\n [ ] adobe\r"]
+[13.748836517,"o","\n "]
+[13.748839242,"o"," [ ] advancedinstaller"]
+[13.748843179,"o","\r\n "]
+[13.748859239,"o"," [ ]"]
+[13.748868185,"o"," adventuregamestudio\r\nv [ ]"]
+[13.748876491,"o"," agda\r"]
+[13.748911765999999,"o","\n\u001b[38;5;14m[\u001b[39m\u001b[38;"]
+[13.748944266,"o","5;14mType to filter ↑↓ move space select enter confirm esc skip\u001b[39m\u001b[38;5;14m]"]
+[13.74895191,"o","\u001b[39m"]
+[13.748981394,"o","\r\u001b[11A\u001b[23C\u001b[?25h"]
+[14.329412783,"o","\u001b[?25l\u001b[23D\r\n\u001b[38;5;14m>\u001b[39m \u001b["]
+[14.329417772,"o","38;5;14m[x]\u001b[39"]
+[14.329419786,"o","m \u001b["]
+[14.329469761,"o","38;5;14magentic\u001b[39m\u001b[K\r\n\r\n\r\n\r"]
+[14.329511486,"o","\n\r\n\r\n\r\n\r\n\r\n"]
+[14.329514081,"o","\r"]
+[14.329515363,"o","\n\r"]
+[14.329521995,"o","\u001b[11A"]
+[14.329561407,"o","\u001b[23C\u001b[?25h"]
+[14.531228907,"o","\u001b[?25l\u001b[23D\u001b[38;5;10m>\u001b[39m .gitignore templates \u001b[38;5;14magentic\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K"]
+[14.531233595,"o","\r\n"]
+[14.531286522,"o","\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h\u001b[11A\u001b[?25h"]
+[14.531346883,"o","\r\n\u001b[?25l"]
+[14.53140446,"o","\u001b[38;5;10m?\u001b[39m .gitattributes \r\n\u001b[38;5;14m>\u001b[39m "]
+[14.53141001,"o","\u001b[38;5;14m"]
+[14.531429606,"o","[x]\u001b[39m "]
+[14.531488323,"o","\u001b[38;5;14mline-endings ★ recommended — * text=auto eol=lf\u001b[39m\r\n [ ] binary-files — mark images, PDFs, archives as binary (no diff)\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14mspace select enter confirm esc skip\u001b[39m\u001b[38;5;14"]
+[14.531492871,"o","m]\u001b["]
+[14.531494945,"o","39"]
+[14.531499293,"o","m\r"]
+[14.531546909,"o","\u001b[3A\u001b[17C\u001b[?25h"]
+[14.793456913,"o","\u001b[?25l\u001b[17D\u001b[38;5;10m>\u001b[39m .gitattributes \u001b[38;5;14mline-endings ★ recommended — * text=auto eol=lf\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h"]
+[14.793500994,"o","\u001b[3A\u001b[?25h\r\n"]
+[14.79869019,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Git config "]
+[14.798693436,"o"," \r"]
+[14.798706159,"o","\n\u001b[38;5;14"]
+[14.798707582,"o","m>\u001b[39"]
+[14.798736705,"o","m \u001b[38;5;14m"]
+[14.79877211,"o","[x]\u001b[39m \u001b[38;5;14mpush.autoSetupRemote = true — auto-set upstream on first push\u001b["]
+[14.798790564,"o","39m"]
+[14.798822359999999,"o","\r\n \u001b[38;5;10"]
+[14.798825396,"o","m"]
+[14.798860149,"o","[x]\u001b[39m help.autocorrect = prompt — suggest corrections for mistyped commands [✓ already set]\r\n \u001b[38;5;10m[x]\u001b[39m diff.algorithm = histogram — cleaner diffs for moved code [✓ already set]\r"]
+[14.798894703,"o","\n [ ] merge.conflictstyle = zdiff3 — show base in conflict markers\r\n \u001b[38;5;10m[x]\u001b[39m "]
+[14.798934125,"o","rerere.enabled = true — remember and reuse conflict resolutions [✓ already set]\r\n [ ] core.pager = delta — beautiful syntax-highlighted diffs (requires cargo)\r\n\u001b[38;5;14m[\u001b["]
+[14.798967135,"o","39m\u001b[38;5;14m↑↓ move space select enter confirm esc skip\u001b[39m\u001b[38;5;14m]\u001b[39m\r\u001b[7"]
+[14.798998345,"o","A\u001b[13C\u001b[?25h"]
+[16.360905838,"o","\u001b[?25l\u001b[13D\r\n \u001b[38;5;10m[x]"]
+[16.360911378,"o","\u001b[39m push.autoSetupRemote = true — auto-set upstream on first push\u001b[K\r"]
+[16.360924382,"o","\n\u001b[38;"]
+[16.360941103,"o","5;14m>"]
+[16.360953676,"o","\u001b[39m "]
+[16.360962542,"o","\u001b[38;5;14m"]
+[16.360971949,"o","[x]\u001b[39"]
+[16.360992756999998,"o","m"]
+[16.360995092,"o"," "]
+[16.36100493,"o","\u001b[38;"]
+[16.361058458,"o","5;14mhelp.autocorrect = prompt — suggest corrections for mistyped commands [✓ already set]\u001b[39m\u001b[K\r\n\r\n\r\n\r\n\r\n"]
+[16.361061383,"o","\r"]
+[16.361111254,"o","\u001b[7A\u001b[13C\u001b[?25h"]
+[16.60295326,"o","\u001b[?25l\u001b[13D\r\n\r\n \u001b[38;5;10m[x]\u001b[39m help.autocorrect = prompt — suggest corrections for mistyped commands [✓ already set]\u001b[K\r"]
+[16.602958269,"o","\n\u001b["]
+[16.602960203,"o","38;5;14"]
+[16.602975932,"o","m>\u001b[39m \u001b["]
+[16.603035902,"o","38;5;14m[x]\u001b[39m \u001b[38;5;14m"]
+[16.603106429,"o","diff.algorithm = histogram — cleaner diffs for moved code [✓ already set]\u001b[39m\u001b[K\r\n\r\n\r\n\r\n\r\u001b[7A\u001b[13C\u001b[?25h"]
+[16.845051357,"o","\u001b[?25l\u001b[13D\r\n\r\n\r\n "]
+[16.845057058,"o"," "]
+[16.845076453,"o","\u001b[38;"]
+[16.845108822,"o","5;10m[x]\u001b[39m diff.algorithm = histogram — cleaner diffs for moved code [✓ already set]\u001b[K\r\n"]
+[16.845154379,"o","\u001b[38;5;14m>\u001b[39m \u001b[38;5;14m[ ]\u001b["]
+[16.845159268,"o","39m \u001b[38;"]
+[16.845218746,"o","5;14mmerge.conflictstyle = zdiff3 — show base in conflict markers\u001b[39m\u001b[K"]
+[16.845243461,"o","\r\n\r"]
+[16.845277922,"o","\n\r\n\r\u001b[7A\u001b["]
+[16.845324808,"o","13C\u001b[?25h"]
+[17.08745653,"o","\u001b[?25l\u001b[13D\r\n\r\n\r\n\r\n [ ] merge.conflictstyle = zdiff3 — show base in conflict markers\u001b[K\r\n\u001b[38;5;14m>\u001b[39m \u001b[38;5;14m[x]\u001b[39m \u001b[38;5;14mrerere.enabled = true — remember and reuse conflict resolutions [✓ already set]\u001b[39m\u001b[K\r\n\r\n\r\u001b[7A\u001b[13C\u001b[?25h"]
+[17.339612333,"o","\u001b[?25l\u001b[13D\r\n\r\n\r\n\r"]
+[17.33962685,"o","\n\r"]
+[17.339628172,"o","\n "]
+[17.339636708,"o"," \u001b["]
+[17.339694294,"o","38;5;10m[x]\u001b[39m rerere.enabled = true — remember and reuse conflict resolutions [✓ already set]\u001b[K\r\n\u001b["]
+[17.339731814,"o","38;5;14m>\u001b[39m \u001b["]
+[17.339734098,"o","38;5;14m"]
+[17.339753243,"o","[ ]"]
+[17.339787413,"o","\u001b[39m \u001b[38;5;14mcore.pager = delta — beautiful syntax-highlighted diffs (requires cargo)\u001b[39m\u001b[K\r\n"]
+[17.339834089,"o","\r\u001b[7A\u001b[13C\u001b[?25h"]
+[17.941085616,"o","\u001b[?25l\u001b[13D\r\n\r\n\r\n\r\n\r\n\r"]
+[17.941092308,"o","\n\u001b[38;5;14"]
+[17.941094612,"o","m"]
+[17.941107286,"o",">\u001b[39m"]
+[17.9411094,"o"," \u001b["]
+[17.94112021,"o","38;5;14m[x]\u001b["]
+[17.941132542,"o","39m \u001b[38;5;14"]
+[17.941141629,"o","mcore.pager = delta — beautiful syntax-highlighted diffs (requires cargo)\u001b[39"]
+[17.941157178,"o","m\u001b[K\r\n"]
+[17.941168579,"o","\r"]
+[17.941179679,"o","\u001b[7A\u001b["]
+[17.941181252,"o","13C\u001b[?25h"]
+[18.142866927,"o","\u001b[?25l\u001b[13D\u001b[38;5;10m>\u001b[39"]
+[18.142888567,"o","m Git config"]
+[18.142890761,"o"," "]
+[18.142892615,"o","\u001b["]
+[18.142904998,"o","38;5;14m"]
+[18.142915006,"o","push.autoSetupRemote = true — auto-set upstream on first push, help.autocorrect = prompt — suggest corrections for mistyped com"]
+[18.14291696,"o","\u001b[39"]
+[18.142918573,"o","m"]
+[18.142926096,"o","\u001b[K\r"]
+[18.14292793,"o","\n\u001b["]
+[18.142947686,"o","38;"]
+[18.142969447,"o","5;14mmands [✓ already set], diff.algorithm = histogram — cleaner diffs for moved code [✓ already set], rerere.enabled = true — remember and reuse\u001b[39"]
+[18.143022555,"o","m\u001b[K\r\n\u001b[38;5;14m conflict resolutions [✓ already set], core.pager = delta — beautiful syntax-highlighted diffs (requires cargo)\u001b[39m\u001b[K\r\n"]
+[18.143037572,"o","\u001b[2K\r\n\u001b[2K\r"]
+[18.143039746,"o","\n\u001b[2K\r\n"]
+[18.143050897,"o","\u001b[2K\r"]
+[18.143062067,"o","\n"]
+[18.143085461,"o","\u001b[2K\r\n\u001b[?25h"]
+[18.143150709,"o","\u001b[5A\u001b[?25h\r\n Summary:\r\n ◆ hooks: conventional-commits, branch-naming\r\n ◆ .gitignore: agentic\r\n"]
+[18.143154296,"o"," ◆ .gitattributes: line-endings\r\n ◆ git config: push.autoSetupRemote, help.autocorrect, diff.algorithm, rerere.enabled, core.pager\r\n\r\n"]
+[18.143217412,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39"]
+[18.143257757,"o","m Apply these changes? (Y/n) \r\u001b[29C"]
+[18.143272053,"o","\u001b[?25h"]
+[19.792232675,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Apply these changes? (Y/n) y "]
+[19.792238877,"o","\u001b[K\r\u001b["]
+[19.792253253,"o","30C\u001b[?25h"]
+[19.994076103,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m>\u001b[39m Apply these changes? \u001b["]
+[19.994083246,"o","38;5;14mYes\u001b["]
+[19.994094867,"o","39m\u001b[K\r\n\u001b[?25h"]
+[19.994151462,"o","\u001b[?25h\r\n"]
+[19.994267556,"o"," ◇ hook 'conventional-commits' installed ✓\r\n"]
+[19.994332987,"o"," ◇ hook 'branch-naming' installed ✓\r\n"]
+[19.994456638,"o"," ◇ .gitignore updated ✓\r\n"]
+[19.994536174,"o"," ◇ .gitattributes applied ✓\r\n"]
+[20.017942778,"o"," ◇ git config applied ✓\r\n\r\n"]
+[20.018006045,"o","\u001b[?25l\u001b[38;5;10m?"]
+[20.018027314,"o","\u001b[39m Save this configuration as a reusable build? "]
+[20.018071277,"o","(y/N) \r\u001b[53C\u001b[?25h"]
+[21.652036013,"o","\u001b[?25l\u001b[53D\u001b[38;5;10m?\u001b[39m Save this configuration as a reusable build? (y/N) n \u001b[K\r\u001b[54"]
+[21.652041623,"o","C\u001b[?25h"]
+[21.853730717,"o","\u001b[?25l\u001b[54D\u001b[38;5;10m>\u001b[39m Save this configuration as a reusable build? \u001b[38;5;14mNo\u001b[39m\u001b[K\r\n\u001b[?25h"]
+[21.853780719,"o","\u001b[?25h\r\n Done\r\n\r\n"]
+[21.854873675,"o","\u001b]697;OSCUnlock=e7d40ddbbcf646adb2a3f991e2653752\u0007\u001b]697;Dir=/home/jheisonmblivecom\u0007"]
+[21.854924569,"o","\u001b]697;Shell=bash\u0007\u001b]697;ShellPath=/usr/bin/bash\u0007"]
+[21.854980031,"o","\u001b]697;WSLDistro=Ubuntu\u0007\u001b]697;PID=384284\u0007"]
+[21.855039231,"o","\u001b]697;ExitCode=0\u0007\u001b]697;TTY=/dev/pts/3\u0007"]
+[21.855096236,"o","\u001b]697;Log=\u0007\u001b]697;User=jheisonmblivecom\u0007"]
+[21.856478418000002,"o","\u001b]697;OSCLock=e7d40ddbbcf646adb2a3f991e2653752\u0007\u001b]697;PreExec\u0007"]
+[22.024984814,"o","\u001b[?2004h\u001b]697;StartPrompt\u0007\u001b[1;32mgitkit@univerlab\u001b[0m:\u001b[1;34m~\u001b[0m$ \u001b]697;EndPrompt\u0007\u001b]697;NewCmd=e7d40ddbbcf646adb2a3f991e2653752\u0007"]
diff --git a/demo/demo.toml b/demo/demo.toml
new file mode 100644
index 0000000..bc3a17d
--- /dev/null
+++ b/demo/demo.toml
@@ -0,0 +1,224 @@
+[demo]
+name = "demo"
+output_dir = "./dist"
+prompt = '\[\e[1;32m\]gitkit@univerlab\[\e[0m\]:\[\e[1;34m\]~\[\e[0m\]$ '
+
+[typing]
+base_ms = 80
+salt_ms = 15
+
+[layout]
+width = 1440
+height = 1080
+fps = 15
+line_height = 1.2000000476837158
+background = "#0b0f14"
+font_family = "IBM Plex Mono"
+
+[[layout.panes]]
+id = "main"
+type = "terminal"
+x = 0
+y = 0
+width = 1440
+height = 1080
+font_family = "monospace"
+font_size = 16
+
+[[timeline]]
+action = "focus"
+pane = "main"
+
+[[timeline]]
+action = "type"
+text = "gitkit clone https://github.com/Univerlab/gitkit.git"
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait_for_quiet"
+quiet_ms = 1500
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 349
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 1316
+
+[[timeline]]
+action = "type"
+text = " "
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 2295
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 2500
+
+[[timeline]]
+action = "type"
+text = " "
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait_for_quiet"
+quiet_ms = 500
+
+[[timeline]]
+action = "type"
+text = " "
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait_for_quiet"
+quiet_ms = 1500
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 180
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 180
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 174
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 183
+
+[[timeline]]
+action = "keypress"
+key = "down"
+
+[[timeline]]
+action = "wait"
+duration_ms = 452
+
+[[timeline]]
+action = "type"
+text = " "
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait_for_quiet"
+quiet_ms = 1500
+
+[[timeline]]
+action = "type"
+text = "y"
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait_for_quiet"
+quiet_ms = 1500
+
+[[timeline]]
+action = "type"
+text = "n"
+human_salt = true
+
+[[timeline]]
+action = "wait"
+duration_ms = 200
+
+[[timeline]]
+action = "keypress"
+key = "enter"
+
+[[timeline]]
+action = "wait_for_quiet"
+quiet_ms = 1500
+
+[[timeline]]
+action = "terminate"
diff --git a/demo/dist/demo.gif b/demo/dist/demo.gif
new file mode 100644
index 0000000..4327a73
Binary files /dev/null and b/demo/dist/demo.gif differ
diff --git a/demo/dist/demo.mp4 b/demo/dist/demo.mp4
new file mode 100644
index 0000000..3e07c1e
Binary files /dev/null and b/demo/dist/demo.mp4 differ
diff --git a/docs/builds.md b/docs/builds.md
new file mode 100644
index 0000000..c346c14
--- /dev/null
+++ b/docs/builds.md
@@ -0,0 +1,37 @@
+---
+title: Builds
+description: Save a repo configuration once and re-apply it to any project.
+order: 7
+---
+
+# Builds
+
+A **build** is a saved snapshot of a repository's gitkit configuration —
+hooks, ignore templates, attributes and config presets — stored as a TOML
+file under `~/.gitkit/builds/`.
+
+Builds turn your preferred setup into a one-liner for every future
+project.
+
+## Workflow
+
+```bash
+# Configure a repo the way you like, then save it
+gitkit build save rust-dev --description "Rust development setup"
+
+# Apply it to any other project
+cd /path/to/other/project
+gitkit build apply rust-dev
+```
+
+## Managing builds
+
+```bash
+gitkit build list # list saved builds
+gitkit build save # save current repo config as a build
+gitkit build apply # apply a saved build
+gitkit build delete # delete a saved build
+```
+
+Because builds are plain TOML files, you can version them in your dotfiles
+and share them across machines.
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
new file mode 100644
index 0000000..ac23f74
--- /dev/null
+++ b/docs/cli-reference.md
@@ -0,0 +1,77 @@
+---
+title: CLI Reference
+description: Every gitkit command and flag.
+order: 8
+---
+
+# CLI Reference
+
+```
+gitkit [command] [options]
+```
+
+Running `gitkit` with no command starts the interactive wizard.
+
+## Core
+
+| Command | Description |
+|---|---|
+| `gitkit` / `gitkit init` | Interactive wizard to configure the repo |
+| `gitkit status` | Show current configuration status |
+| `gitkit clone [dir]` | Clone a repository and run the wizard |
+| `gitkit clone -b ` | Clone a specific branch |
+
+## Hooks
+
+| Command | Description |
+|---|---|
+| `gitkit hooks add ` | Install a built-in hook (hook type inferred) |
+| `gitkit hooks add ` | Install a custom shell command as a hook |
+| `gitkit hooks list` | List installed hooks |
+| `gitkit hooks list --available` | Show all built-in hooks with descriptions |
+| `gitkit hooks remove ` | Remove an installed hook |
+| `gitkit hooks show ` | Print hook content |
+
+## Ignore
+
+| Command | Description |
+|---|---|
+| `gitkit ignore add ` | Generate/merge `.gitignore` via gitignore.io |
+| `gitkit ignore list [filter]` | List available templates |
+
+## 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` |
+| `gitkit config show` | Show current git config values |
+
+Scope: `--global` (all repos) or `--local` (current repo). Default is
+`--local` inside a repo, `--global` otherwise.
+
+## Builds
+
+| Command | Description |
+|---|---|
+| `gitkit build list` | List saved builds |
+| `gitkit build save ` | Save current repo config as a build |
+| `gitkit build apply ` | Apply a saved build |
+| `gitkit build delete ` | Delete a saved build |
+
+## Global flags
+
+| Flag | Description |
+|---|---|
+| `--yes`, `-y` | Skip confirmation prompts |
+| `--force`, `-f` | Overwrite existing files |
+| `--dry-run` | Preview changes without applying |
+| `--help` | Show help for any command |
+| `--version` | Show gitkit version |
diff --git a/docs/config-presets.md b/docs/config-presets.md
new file mode 100644
index 0000000..501184d
--- /dev/null
+++ b/docs/config-presets.md
@@ -0,0 +1,43 @@
+---
+title: Config Presets
+description: Curated git config presets with scope control and idempotency detection.
+order: 6
+---
+
+# Config Presets
+
+Gitkit ships curated git config presets — small sets of options that make
+day-to-day git noticeably better:
+
+| Preset | What it sets |
+|---|---|
+| `defaults` | `push.autoSetupRemote`, `help.autocorrect`, `diff.algorithm histogram` |
+| `advanced` | `merge.conflictstyle zdiff3`, `rerere.enabled` |
+| `delta` | `core.pager delta` (requires [delta](https://github.com/dandavison/delta)) |
+
+```bash
+gitkit config apply defaults
+gitkit config apply advanced
+gitkit config show # current git config values
+```
+
+## Scope
+
+- `--global` — apply to global git config (all repos)
+- `--local` — apply to the current repo only
+- Default: `--local` if inside a repo, `--global` otherwise
+
+## Idempotency
+
+Options already set with the same value are detected and skipped:
+
+```
+$ gitkit config apply defaults --global
+✓ push.autoSetupRemote = true (already set)
+✓ help.autocorrect = prompt (already set)
+✓ diff.algorithm = histogram (already set)
+
+All configs already applied.
+```
+
+Running a preset twice never duplicates or clobbers anything.
diff --git a/docs/hooks.md b/docs/hooks.md
new file mode 100644
index 0000000..19ea89e
--- /dev/null
+++ b/docs/hooks.md
@@ -0,0 +1,41 @@
+---
+title: Hooks
+description: Built-in hooks (conventional commits, secret detection, branch naming) and custom shell commands.
+order: 4
+---
+
+# Hooks
+
+## Built-in hooks
+
+Built-ins are embedded in the binary — no network required.
+
+| Name | Hook | Description |
+|---|---|---|
+| `conventional-commits` | `commit-msg` | Validates Conventional Commits format |
+| `no-secrets` | `pre-commit` | Detects common secret patterns in staged changes |
+| `branch-naming` | `pre-commit` | Validates branch name matches convention |
+
+```bash
+gitkit hooks list --available # see all built-ins with descriptions
+gitkit hooks add no-secrets # install one (hook type inferred)
+```
+
+## Custom hooks
+
+Wire any shell command into a git hook:
+
+```bash
+gitkit hooks add pre-push "cargo test"
+```
+
+## Managing hooks
+
+```bash
+gitkit hooks list # list installed hooks
+gitkit hooks show # print hook content
+gitkit hooks remove # remove an installed hook
+```
+
+The `gitkit` wizard also shows installed hooks, pre-selects them and
+allows removal interactively.
diff --git a/docs/ignore-and-attributes.md b/docs/ignore-and-attributes.md
new file mode 100644
index 0000000..52e8266
--- /dev/null
+++ b/docs/ignore-and-attributes.md
@@ -0,0 +1,35 @@
+---
+title: Ignore & Attributes
+description: Generate .gitignore from templates and apply .gitattributes presets.
+order: 5
+---
+
+# Ignore & Attributes
+
+## `.gitignore`
+
+Gitkit generates and merges `.gitignore` files from
+[gitignore.io](https://www.toptal.com/developers/gitignore) templates plus
+its own built-ins (like `agentic` for AI coding agents):
+
+```bash
+gitkit ignore add rust,vscode,agentic # generate/merge templates
+gitkit ignore list # list available templates
+gitkit ignore list python # filter the list
+```
+
+Existing patterns are merged, not overwritten — your manual entries
+survive.
+
+## `.gitattributes`
+
+```bash
+gitkit attributes init
+```
+
+Applies a line-endings preset (`* text=auto eol=lf`) and binary-file
+attributes so checkouts behave identically across Linux, macOS and
+Windows.
+
+Both files can also be configured interactively from the `gitkit` wizard,
+which offers a filterable search across all templates.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..b23e971
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,52 @@
+---
+title: Gitkit
+description: Set up a git repo the way you actually work — hooks, .gitignore, .gitattributes and git config in one guided flow.
+order: 1
+---
+
+# Gitkit
+
+Gitkit sets up a git repository the way you actually work: one guided flow
+for hooks, `.gitignore`, `.gitattributes` and git config. It is a single
+Rust binary — no Node.js, no Python, no runtime dependencies.
+
+## Why gitkit
+
+Every new repository needs the same ritual: pick a `.gitignore`, normalize
+line endings, install a commit-message hook, set the git config options
+you always set. Doing it by hand is error-prone; doing it with four
+different tools (husky, gitignore.io, dotfiles, …) drags in runtimes and
+copy-paste. Gitkit folds the whole ritual into one interactive wizard —
+and lets you **save the result as a build** you can re-apply to any
+project with one command.
+
+- **Guided repo setup** — `gitkit` (no arguments) walks you through
+ everything, showing what is already configured.
+- **Status overview** — `gitkit status` shows hooks, ignore patterns,
+ attributes and config at a glance.
+- **Clone and bootstrap** — `gitkit clone ` clones and drops straight
+ into the wizard.
+- **Hook management** — built-in hooks (conventional commits, secret
+ detection, branch naming) or your own shell command.
+- **Ignore & attribute presets** — all gitignore.io templates plus
+ built-ins, line-ending and binary presets.
+- **Curated git config** — practical presets with `--global`/`--local`
+ scope and idempotency detection.
+- **Builds** — save a configuration once, apply it everywhere.
+
+## How the documentation is organized
+
+- [Installation](installation.md) — install, update and uninstall.
+- [Quick Start](quickstart.md) — the wizard and the one-liner workflow.
+- [Hooks](hooks.md) — built-in and custom hooks.
+- [Ignore & Attributes](ignore-and-attributes.md) — `.gitignore` and `.gitattributes`.
+- [Config Presets](config-presets.md) — curated git config, scopes, idempotency.
+- [Builds](builds.md) — save and reuse configurations.
+- [CLI Reference](cli-reference.md) — every command and flag.
+
+## Part of UniverLab
+
+Gitkit is an experiment of [UniverLab](https://github.com/UniverLab),
+an open computational laboratory. It follows the lab's engineering
+principles: one tool one job, reproducibility first, offline-friendly
+design.
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..505bc4c
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,50 @@
+---
+title: Installation
+description: Install gitkit with the quick installer, cargo, or from GitHub Releases.
+order: 2
+---
+
+# Installation
+
+## Quick install (recommended)
+
+**Linux / macOS:**
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/UniverLab/gitkit/main/scripts/install.sh | sh
+```
+
+**Windows (PowerShell):**
+
+```powershell
+irm https://raw.githubusercontent.com/UniverLab/gitkit/main/scripts/install.ps1 | iex
+```
+
+## Via cargo
+
+```bash
+cargo install gitkit
+```
+
+Available on [crates.io](https://crates.io/crates/gitkit).
+
+## GitHub Releases
+
+Precompiled binaries for Linux x86_64, macOS x86_64/ARM64 and Windows
+x86_64 are published on the
+[Releases](https://github.com/UniverLab/gitkit/releases) page.
+
+## Uninstall
+
+**Linux / macOS:**
+
+```bash
+rm -f ~/.local/bin/gitkit
+rm -rf ~/.gitkit/ # saved builds (optional)
+```
+
+**Windows (PowerShell):**
+
+```powershell
+Remove-Item "$env:LOCALAPPDATA\gitkit\gitkit.exe" -Force
+```
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..a022725
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,76 @@
+---
+title: Quick Start
+description: The wizard, the status overview, and clone-and-configure in one command.
+order: 3
+---
+
+# Quick Start
+
+## The wizard
+
+Run gitkit with no arguments inside (or outside) a repository:
+
+```bash
+gitkit
+# or explicitly:
+gitkit init
+```
+
+The wizard guides you step by step and shows what is already configured:
+
+- **Hooks** — shows installed hooks, pre-selects them, allows removal.
+- **`.gitignore`** — filterable search across all gitignore.io templates
+ plus built-ins.
+- **`.gitattributes`** — line endings and binary file presets.
+- **Git config** — shows current values, allows removal.
+- **Custom hooks** — interactive picker for hook type selection.
+
+If the current directory is not a git repository, gitkit initializes one
+automatically.
+
+## Clone and configure in one command
+
+```bash
+gitkit clone https://github.com/user/repo # clone + wizard
+gitkit clone -b develop https://github.com/user/repo # specific branch
+gitkit clone https://github.com/user/repo my-project # custom directory
+```
+
+The wizard runs automatically after cloning.
+
+## Direct commands
+
+Everything the wizard does is also available as direct commands:
+
+```bash
+gitkit hooks add conventional-commits
+gitkit ignore add rust,vscode,agentic
+gitkit attributes init
+gitkit config apply defaults
+```
+
+## Check the result
+
+```bash
+gitkit status
+```
+
+```
+Hooks:
+ ✓ conventional-commits (commit-msg)
+ ✓ custom: pre-push → "cargo test"
+
+.gitignore:
+ ✓ 14 patterns
+
+.gitattributes:
+ ✓ line-endings (eol=lf)
+
+Git config (global):
+ ✓ push.autoSetupRemote = true
+ ✓ help.autocorrect = prompt
+ ✓ diff.algorithm = histogram
+```
+
+When you are happy with a setup, [save it as a build](builds.md) and apply
+it to every future project with one command.
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 86ff107..3db2291 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -47,6 +47,26 @@ try {
Fail "Download failed: $_`nURL: $Url"
}
+# --- verify checksum ---
+# Match against SHA256SUMS.txt from the same release. Missing sums file (older
+# releases) -> skip; a present-but-mismatched checksum is fatal.
+$SumsUrl = "https://github.com/$Repo/releases/download/$Tag/SHA256SUMS.txt"
+$SumsOk = $false
+try {
+ Invoke-WebRequest -Uri $SumsUrl -OutFile "$Tmp\SHA256SUMS.txt" -UseBasicParsing
+ $SumsOk = $true
+} catch {
+ Info "checksum" "SHA256SUMS.txt not found for $Tag - skipping verification"
+}
+if ($SumsOk) {
+ $line = Select-String -Path "$Tmp\SHA256SUMS.txt" -Pattern ([regex]::Escape($Archive)) | Select-Object -First 1
+ if (-not $line) { Fail "No checksum listed for $Archive in SHA256SUMS.txt" }
+ $expected = ($line.Line -split '\s+')[0].ToLower()
+ $actual = (Get-FileHash "$Tmp\$Archive" -Algorithm SHA256).Hash.ToLower()
+ if ($actual -ne $expected) { Fail "Checksum mismatch for $Archive (expected $expected, got $actual)" }
+ Info "checksum" "verified"
+}
+
# --- extract ---
Expand-Archive -Path "$Tmp\$Archive" -DestinationPath $Tmp -Force
$extracted = Join-Path $Tmp $Binary
diff --git a/scripts/install.sh b/scripts/install.sh
index 633af69..43df96f 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -50,6 +50,29 @@ 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"
+# --- verify checksum ---
+# Match against SHA256SUMS.txt from the same release. Missing sums file (older
+# releases) or no sha256 tool → skip; a present-but-mismatched checksum is fatal.
+SUMS_URL="https://github.com/$REPO/releases/download/${TAG}/SHA256SUMS.txt"
+if curl -fsSL -o "$TMPDIR/SHA256SUMS.txt" "$SUMS_URL" 2>/dev/null; then
+ EXPECTED=$(awk -v f="$ARCHIVE" '$2 == f { print $1 }' "$TMPDIR/SHA256SUMS.txt" | head -1)
+ [ -n "$EXPECTED" ] || error "No checksum listed for $ARCHIVE in SHA256SUMS.txt"
+ if command -v sha256sum >/dev/null 2>&1; then
+ ACTUAL=$(sha256sum "$TMPDIR/$ARCHIVE" | awk '{ print $1 }')
+ elif command -v shasum >/dev/null 2>&1; then
+ ACTUAL=$(shasum -a 256 "$TMPDIR/$ARCHIVE" | awk '{ print $1 }')
+ else
+ ACTUAL=""
+ info "checksum" "no sha256 tool found — skipping verification"
+ fi
+ if [ -n "$ACTUAL" ]; then
+ [ "$ACTUAL" = "$EXPECTED" ] || error "Checksum mismatch for $ARCHIVE (expected $EXPECTED, got $ACTUAL)"
+ info "checksum" "verified"
+ fi
+else
+ info "checksum" "SHA256SUMS.txt not found for $TAG — skipping verification"
+fi
+
# --- extract ---
tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR"
[ -f "$TMPDIR/$BINARY" ] || error "Binary not found in archive"
diff --git a/src/builds/mod.rs b/src/builds/mod.rs
new file mode 100644
index 0000000..9590b70
--- /dev/null
+++ b/src/builds/mod.rs
@@ -0,0 +1,520 @@
+use anyhow::{Context, Result};
+use clap::Subcommand;
+use serde::{Deserialize, Serialize};
+use std::{fs, path::PathBuf};
+
+#[derive(Subcommand)]
+pub enum BuildCommand {
+ /// List saved builds
+ List,
+ /// Apply a saved build
+ Apply {
+ /// Build name
+ name: String,
+ #[arg(long)]
+ dry_run: bool,
+ },
+ /// Save current configuration as a build
+ Save {
+ /// Build name
+ name: String,
+ /// Optional description
+ #[arg(short, long)]
+ description: Option,
+ },
+ /// Delete a saved build
+ Delete {
+ /// Build name
+ name: String,
+ },
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Build {
+ pub name: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default)]
+ pub hooks: HooksConfig,
+ #[serde(default)]
+ pub gitignore: GitignoreConfig,
+ #[serde(default)]
+ pub gitattributes: GitattributesConfig,
+ #[serde(default)]
+ pub config: ConfigBuild,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct HooksConfig {
+ #[serde(default)]
+ pub builtins: Vec,
+ #[serde(default)]
+ pub custom: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CustomHook {
+ pub hook: String,
+ pub command: String,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct GitignoreConfig {
+ #[serde(default)]
+ pub templates: Vec,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct GitattributesConfig {
+ #[serde(default)]
+ pub presets: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ConfigBuild {
+ #[serde(default)]
+ pub keys: Vec,
+ #[serde(default = "default_scope")]
+ pub scope: String,
+}
+
+impl Default for ConfigBuild {
+ fn default() -> Self {
+ Self {
+ keys: Vec::new(),
+ scope: default_scope(),
+ }
+ }
+}
+
+fn default_scope() -> String {
+ "local".to_string()
+}
+
+pub(crate) fn builds_dir() -> Result {
+ let home = std::env::var("HOME")
+ .or_else(|_| std::env::var("USERPROFILE"))
+ .context("Neither HOME nor USERPROFILE environment variable is set")?;
+ Ok(PathBuf::from(home).join(".gitkit").join("builds"))
+}
+
+fn build_path(name: &str) -> Result {
+ anyhow::ensure!(
+ !name.is_empty() && !name.contains(['/', '\\']) && name != "." && name != "..",
+ "Invalid build name '{name}' — use a simple name without path separators"
+ );
+ Ok(builds_dir()?.join(format!("{name}.toml")))
+}
+
+pub fn run(cmd: BuildCommand) -> Result<()> {
+ match cmd {
+ BuildCommand::List => list(),
+ BuildCommand::Apply { name, dry_run } => apply(&name, dry_run),
+ BuildCommand::Save { name, description } => save(&name, description.as_deref()),
+ BuildCommand::Delete { name } => delete(&name),
+ }
+}
+
+fn list() -> Result<()> {
+ let dir = builds_dir()?;
+ if !dir.exists() {
+ println!("No builds saved.");
+ return Ok(());
+ }
+
+ let builds: Vec<_> = fs::read_dir(&dir)?
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
+ .collect();
+
+ if builds.is_empty() {
+ println!("No builds saved.");
+ return Ok(());
+ }
+
+ println!("Saved builds:\n");
+ for entry in builds {
+ let path = entry.path();
+ let name = path.file_stem().unwrap().to_string_lossy();
+ let content = fs::read_to_string(&path).unwrap_or_default();
+ let build: Build = match toml::from_str(&content) {
+ Ok(b) => b,
+ Err(_) => continue,
+ };
+ let desc = if build.description.is_empty() {
+ ""
+ } else {
+ &format!(" — {}", build.description)
+ };
+ println!(" {name}{desc}");
+ if !build.hooks.builtins.is_empty() || !build.hooks.custom.is_empty() {
+ let hooks: Vec<&str> = build
+ .hooks
+ .builtins
+ .iter()
+ .map(|s| s.as_str())
+ .chain(build.hooks.custom.iter().map(|c| c.hook.as_str()))
+ .collect();
+ println!(" hooks: {}", hooks.join(", "));
+ }
+ if !build.gitignore.templates.is_empty() {
+ println!(" gitignore: {}", build.gitignore.templates.join(", "));
+ }
+ if !build.gitattributes.presets.is_empty() {
+ println!(
+ " gitattributes: {}",
+ build.gitattributes.presets.join(", ")
+ );
+ }
+ if !build.config.keys.is_empty() {
+ println!(
+ " config ({}): {}",
+ build.config.scope,
+ build.config.keys.join(", ")
+ );
+ }
+ println!();
+ }
+
+ Ok(())
+}
+
+fn apply(name: &str, dry_run: bool) -> Result<()> {
+ let path = build_path(name)?;
+ anyhow::ensure!(path.exists(), "Build '{name}' not found");
+
+ let content = fs::read_to_string(&path).context("Failed to read build file")?;
+ let build: Build = toml::from_str(&content).context("Failed to parse build file")?;
+
+ if dry_run {
+ println!("[dry-run] Would apply build '{name}':");
+ if !build.hooks.builtins.is_empty() {
+ println!(" hooks: {}", build.hooks.builtins.join(", "));
+ }
+ for custom in &build.hooks.custom {
+ println!(" custom hook: {} → {}", custom.hook, custom.command);
+ }
+ if !build.gitignore.templates.is_empty() {
+ println!(" gitignore: {}", build.gitignore.templates.join(", "));
+ }
+ if !build.gitattributes.presets.is_empty() {
+ println!(
+ " gitattributes: {}",
+ build.gitattributes.presets.join(", ")
+ );
+ }
+ if !build.config.keys.is_empty() {
+ println!(
+ " config ({}): {}",
+ build.config.scope,
+ build.config.keys.join(", ")
+ );
+ }
+ return Ok(());
+ }
+
+ apply_build(&build)?;
+ println!(" ✓ Build '{name}' applied");
+ Ok(())
+}
+
+pub(crate) fn apply_build(build: &Build) -> Result<()> {
+ let cargo_available = std::process::Command::new("cargo")
+ .arg("--version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false);
+
+ for name in &build.hooks.builtins {
+ crate::hooks::install_builtin(name, false)?;
+ println!(" ◇ hook '{name}' installed ✓");
+ }
+ for custom in &build.hooks.custom {
+ crate::hooks::install_custom(&custom.hook, &custom.command, false)?;
+ println!(" ◇ custom hook '{}' installed ✓", custom.hook);
+ }
+ if !build.gitignore.templates.is_empty() {
+ let joined = build.gitignore.templates.join(",");
+ crate::ignore::add_templates(&joined, false)?;
+ println!(" ◇ .gitignore updated ✓");
+ }
+ if !build.gitattributes.presets.is_empty() {
+ let presets: Vec<&str> = build
+ .gitattributes
+ .presets
+ .iter()
+ .map(|s| s.as_str())
+ .collect();
+ crate::attributes::apply_presets(&presets)?;
+ println!(" ◇ .gitattributes applied ✓");
+ }
+ if !build.config.keys.is_empty() {
+ let scope = if build.config.scope == "global" {
+ crate::config::ConfigScope::Global
+ } else {
+ crate::config::ConfigScope::Local
+ };
+ let keys: Vec<&str> = build.config.keys.iter().map(|s| s.as_str()).collect();
+ crate::config::apply_config_keys(&keys, cargo_available, scope)?;
+ println!(" ◇ git config applied ✓");
+ }
+
+ Ok(())
+}
+
+pub(crate) fn save(name: &str, description: Option<&str>) -> Result<()> {
+ let path = build_path(name)?;
+ if path.exists() {
+ anyhow::bail!("Build '{name}' already exists. Delete it first or choose another name.");
+ }
+
+ let build = capture_current_config(name, description)?;
+
+ let dir = builds_dir()?;
+ fs::create_dir_all(&dir).context("Failed to create builds directory")?;
+
+ let content = toml::to_string_pretty(&build).context("Failed to serialize build")?;
+ fs::write(&path, content).context("Failed to write build file")?;
+
+ println!(" ✓ Build '{name}' saved to {}", path.display());
+ Ok(())
+}
+
+pub(crate) fn capture_current_config(name: &str, description: Option<&str>) -> Result {
+ let root = crate::utils::find_repo_root()?;
+
+ let mut builtins = Vec::new();
+ let mut custom = Vec::new();
+ let hooks_dir = root.join(".git").join("hooks");
+ if hooks_dir.exists() {
+ if let Ok(entries) = fs::read_dir(&hooks_dir) {
+ for entry in entries.filter_map(|e| e.ok()) {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ let hook_name = entry.file_name().to_string_lossy().to_string();
+ if hook_name.ends_with(".bak") || hook_name.ends_with(".sample") {
+ continue;
+ }
+ let content = fs::read_to_string(&path).unwrap_or_default();
+ if let Some(b) = crate::hooks::detect_builtin(&hook_name, &content) {
+ builtins.push(b.name.to_string());
+ } else if crate::hooks::valid_hook_names().contains(&hook_name.as_str()) {
+ if let Some(command) = extract_custom_command(&content) {
+ custom.push(CustomHook {
+ hook: hook_name,
+ command,
+ });
+ }
+ }
+ }
+ }
+ }
+
+ let gitignore_path = root.join(".gitignore");
+ let templates = if gitignore_path.exists() {
+ let content = fs::read_to_string(&gitignore_path)?;
+ detect_gitignore_templates(&content)
+ } else {
+ Vec::new()
+ };
+
+ let gitattributes_path = root.join(".gitattributes");
+ let presets = if gitattributes_path.exists() {
+ let content = fs::read_to_string(&gitattributes_path)?;
+ detect_gitattributes_presets(&content)
+ } else {
+ Vec::new()
+ };
+
+ let mut config_keys = Vec::new();
+ for option in crate::config::CONFIG_OPTIONS {
+ if option.key == "core.pager" {
+ continue;
+ }
+ if let Some(expected) = option.value {
+ if crate::utils::git_config_get(option.key, "--local").as_deref() == Some(expected) {
+ config_keys.push(option.key.to_string());
+ }
+ }
+ }
+
+ Ok(Build {
+ name: name.to_string(),
+ description: description.unwrap_or("").to_string(),
+ hooks: HooksConfig { builtins, custom },
+ gitignore: GitignoreConfig { templates },
+ gitattributes: GitattributesConfig { presets },
+ config: ConfigBuild {
+ keys: config_keys,
+ scope: "local".to_string(),
+ },
+ })
+}
+
+/// Recovers the command from a custom hook script (shebang + `set -e` + command).
+fn extract_custom_command(content: &str) -> Option {
+ let lines: Vec<&str> = content
+ .lines()
+ .map(str::trim_end)
+ .filter(|l| {
+ let t = l.trim();
+ !t.is_empty() && !t.starts_with('#') && t != "set -e"
+ })
+ .collect();
+ if lines.is_empty() {
+ None
+ } else {
+ Some(lines.join("\n"))
+ }
+}
+
+fn detect_gitignore_templates(content: &str) -> Vec {
+ let mut templates = Vec::new();
+
+ let patterns = [
+ ("rust", "target/"),
+ ("node", "node_modules/"),
+ ("python", "__pycache__/"),
+ ("vscode", ".vscode/"),
+ ("agentic", ".kiro/"),
+ ];
+
+ for (name, pattern) in &patterns {
+ if content.contains(pattern) {
+ templates.push(name.to_string());
+ }
+ }
+
+ templates
+}
+
+fn detect_gitattributes_presets(content: &str) -> Vec {
+ let mut presets = Vec::new();
+ if content.contains("eol=lf") {
+ presets.push("line-endings".to_string());
+ }
+ if content.contains("binary") {
+ presets.push("binary-files".to_string());
+ }
+ presets
+}
+
+fn delete(name: &str) -> Result<()> {
+ let path = build_path(name)?;
+ anyhow::ensure!(path.exists(), "Build '{name}' not found");
+ fs::remove_file(&path).context("Failed to delete build file")?;
+ println!(" ✓ Build '{name}' deleted");
+ Ok(())
+}
+
+pub(crate) fn list_build_names() -> Vec {
+ builds_dir()
+ .ok()
+ .and_then(|dir| {
+ if !dir.exists() {
+ return None;
+ }
+ Some(
+ fs::read_dir(&dir)
+ .ok()?
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
+ .map(|e| e.path().file_stem().unwrap().to_string_lossy().to_string())
+ .collect(),
+ )
+ })
+ .unwrap_or_default()
+}
+
+pub(crate) fn load_build(name: &str) -> Result {
+ let path = build_path(name)?;
+ anyhow::ensure!(path.exists(), "Build '{name}' not found");
+ let content = fs::read_to_string(&path).context("Failed to read build file")?;
+ toml::from_str(&content).context("Failed to parse build file")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn build_serializes_to_toml() {
+ let build = Build {
+ name: "test".to_string(),
+ description: "Test build".to_string(),
+ hooks: HooksConfig {
+ builtins: vec!["conventional-commits".to_string()],
+ custom: Vec::new(),
+ },
+ gitignore: GitignoreConfig {
+ templates: vec!["rust".to_string()],
+ },
+ gitattributes: GitattributesConfig {
+ presets: vec!["line-endings".to_string()],
+ },
+ config: ConfigBuild {
+ keys: vec!["push.autoSetupRemote".to_string()],
+ scope: "local".to_string(),
+ },
+ };
+
+ let toml_str = toml::to_string_pretty(&build).unwrap();
+ assert!(toml_str.contains("conventional-commits"));
+ assert!(toml_str.contains("rust"));
+
+ let parsed: Build = toml::from_str(&toml_str).unwrap();
+ assert_eq!(parsed.name, "test");
+ assert_eq!(parsed.hooks.builtins, vec!["conventional-commits"]);
+ }
+
+ #[test]
+ fn build_deserializes_with_defaults() {
+ let toml_str = r#"
+name = "minimal"
+description = ""
+"#;
+ let build: Build = toml::from_str(toml_str).unwrap();
+ assert_eq!(build.name, "minimal");
+ assert!(build.hooks.builtins.is_empty());
+ assert!(build.gitignore.templates.is_empty());
+ assert_eq!(build.config.scope, "local");
+ }
+
+ #[test]
+ fn detect_gitignore_templates_finds_rust() {
+ let content = "# Rust\ntarget/\n*.pdb\n";
+ let templates = detect_gitignore_templates(content);
+ assert!(templates.contains(&"rust".to_string()));
+ }
+
+ #[test]
+ fn detect_gitattributes_presets_finds_line_endings() {
+ let content = "* text=auto eol=lf\n";
+ let presets = detect_gitattributes_presets(content);
+ assert!(presets.contains(&"line-endings".to_string()));
+ }
+
+ #[test]
+ fn extract_custom_command_recovers_command() {
+ let script = "#!/bin/sh\nset -e\ncargo test\n";
+ assert_eq!(
+ extract_custom_command(script).as_deref(),
+ Some("cargo test")
+ );
+ }
+
+ #[test]
+ fn extract_custom_command_returns_none_for_empty_script() {
+ assert!(extract_custom_command("#!/bin/sh\nset -e\n").is_none());
+ }
+
+ #[test]
+ fn build_path_rejects_invalid_names() {
+ assert!(build_path("").is_err());
+ assert!(build_path("../evil").is_err());
+ assert!(build_path("a/b").is_err());
+ assert!(build_path("ok-name").is_ok());
+ }
+}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 41301b3..6221bbf 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use clap::{Subcommand, ValueEnum};
use std::process::Command;
-use crate::utils::confirm;
+use crate::utils::{confirm, find_repo_root};
#[derive(Subcommand)]
pub enum ConfigCommand {
@@ -13,7 +13,13 @@ pub enum ConfigCommand {
yes: bool,
#[arg(long)]
dry_run: bool,
+ #[arg(long, conflicts_with = "local")]
+ global: bool,
+ #[arg(long, conflicts_with = "global")]
+ local: bool,
},
+ /// Show current git config values
+ Show,
}
#[derive(ValueEnum, Clone)]
@@ -27,15 +33,90 @@ pub enum Preset {
}
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),
+ match cmd {
+ ConfigCommand::Apply {
+ preset,
+ yes,
+ dry_run,
+ global,
+ local,
+ } => {
+ let scope = determine_scope(global, local);
+ match preset {
+ Preset::Defaults => apply_defaults(dry_run, scope),
+ Preset::Advanced => apply_advanced(dry_run, scope),
+ Preset::Delta => apply_delta(yes, dry_run, scope),
+ }
+ }
+ ConfigCommand::Show => show_config(),
+ }
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum ConfigScope {
+ Global,
+ Local,
+}
+
+fn determine_scope(global: bool, local: bool) -> ConfigScope {
+ if global {
+ ConfigScope::Global
+ } else if local || find_repo_root().is_ok() {
+ ConfigScope::Local
+ } else {
+ ConfigScope::Global
+ }
+}
+
+fn scope_flag(scope: ConfigScope) -> &'static str {
+ match scope {
+ ConfigScope::Global => "--global",
+ ConfigScope::Local => "--local",
+ }
+}
+
+fn show_config() -> Result<()> {
+ println!("Git config (global):");
+ show_scope_config("--global");
+ println!();
+ println!("Git config (local):");
+ show_scope_config("--local");
+ Ok(())
+}
+
+fn show_scope_config(scope: &str) {
+ let configs = [
+ "push.autoSetupRemote",
+ "help.autocorrect",
+ "diff.algorithm",
+ "merge.conflictstyle",
+ "rerere.enabled",
+ "core.pager",
+ ];
+
+ let mut any = false;
+ for key in &configs {
+ if let Some(value) = git_config_get(key, scope) {
+ println!(" {key} = {value}");
+ any = true;
+ }
+ }
+
+ if !any {
+ println!(" (none)");
+ }
+}
+
+fn git_config_get(key: &str, scope: &str) -> Option {
+ let output = Command::new("git")
+ .args(["config", scope, "--get", key])
+ .output()
+ .ok()?;
+
+ if output.status.success() {
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ } else {
+ None
}
}
@@ -87,37 +168,43 @@ pub(crate) const CONFIG_OPTIONS: &[ConfigOption] = &[
];
/// Apply selected config option keys. Used by the interactive wizard.
-pub(crate) fn apply_config_keys(keys: &[&str], cargo_available: bool) -> Result<()> {
+pub(crate) fn apply_config_keys(
+ keys: &[&str],
+ cargo_available: bool,
+ scope: ConfigScope,
+) -> 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)?;
- }
+ "core.pager" => apply_pager_config(cargo_available, scope)?,
+ _ => apply_single_config(key, scope)?,
}
}
Ok(())
}
+fn apply_pager_config(cargo_available: bool, scope: ConfigScope) -> Result<()> {
+ 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, scope)?;
+ }
+ Ok(())
+}
+
+fn apply_single_config(key: &str, scope: ConfigScope) -> Result<()> {
+ 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, scope)
+}
+
type GitConfigs = &'static [(&'static str, &'static str)];
const DEFAULTS: GitConfigs = &[
@@ -138,18 +225,18 @@ const DELTA_CONFIGS: GitConfigs = &[
("delta.side-by-side", "true"),
];
-fn apply_defaults(dry_run: bool) -> Result<()> {
- apply_configs(DEFAULTS, dry_run)
+fn apply_defaults(dry_run: bool, scope: ConfigScope) -> Result<()> {
+ apply_configs(DEFAULTS, dry_run, scope)
}
-fn apply_advanced(dry_run: bool) -> Result<()> {
+fn apply_advanced(dry_run: bool, scope: ConfigScope) -> Result<()> {
println!(
"Warning: merge.conflictstyle=zdiff3 may cause issues with GitHub Desktop and GUI merge tools."
);
- apply_configs(ADVANCED, dry_run)
+ apply_configs(ADVANCED, dry_run, scope)
}
-fn apply_delta(yes: bool, dry_run: bool) -> Result<()> {
+fn apply_delta(yes: bool, dry_run: bool, scope: ConfigScope) -> Result<()> {
if !delta_installed() {
if !confirm(
"git-delta is not installed. Install via `cargo install git-delta`?",
@@ -166,29 +253,54 @@ fn apply_delta(yes: bool, dry_run: bool) -> Result<()> {
}
println!(
"Note: delta.side-by-side=true may look wrong in narrow terminals. \
- Disable with: git config --global delta.side-by-side false"
+ Disable with: git config {} delta.side-by-side false",
+ scope_flag(scope)
);
- apply_configs(DELTA_CONFIGS, dry_run)
+ apply_configs(DELTA_CONFIGS, dry_run, scope)
}
-fn apply_configs(configs: GitConfigs, dry_run: bool) -> Result<()> {
+fn apply_configs(configs: GitConfigs, dry_run: bool, scope: ConfigScope) -> Result<()> {
+ let flag = scope_flag(scope);
+ let mut already_set = 0;
+
for (key, value) in configs {
- if dry_run {
- println!("[dry-run] git config --global {key} {value}");
+ let current = git_config_get(key, flag);
+
+ if current.as_deref() == Some(value) {
+ println!("✓ {key} = {value} (already set)");
+ already_set += 1;
+ } else if dry_run {
+ println!("[dry-run] git config {flag} {key} {value}");
} else {
- git_config_set(key, value)?;
- println!("Set {key} = {value}");
+ git_config_set(key, value, scope)?;
+ println!("✓ Set {key} = {value}");
}
}
+
+ if already_set == configs.len() {
+ println!("\nAll configs already applied.");
+ }
+
Ok(())
}
-fn git_config_set(key: &str, value: &str) -> Result<()> {
+fn git_config_set(key: &str, value: &str, scope: ConfigScope) -> Result<()> {
+ let flag = scope_flag(scope);
let status = Command::new("git")
- .args(["config", "--global", key, value])
+ .args(["config", flag, key, value])
.status()
.with_context(|| format!("Failed to run git config for '{key}'"))?;
- anyhow::ensure!(status.success(), "git config --global {key} {value} failed");
+ anyhow::ensure!(status.success(), "git config {flag} {key} {value} failed");
+ Ok(())
+}
+
+pub(crate) fn remove_config_key(key: &str, scope: ConfigScope) -> Result<()> {
+ let flag = scope_flag(scope);
+ let status = Command::new("git")
+ .args(["config", flag, "--unset", key])
+ .status()
+ .with_context(|| format!("Failed to unset git config for '{key}'"))?;
+ anyhow::ensure!(status.success(), "git config {flag} --unset {key} failed");
Ok(())
}
@@ -224,18 +336,35 @@ mod tests {
#[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);
+ let result = apply_configs(DEFAULTS, true, ConfigScope::Global);
assert!(result.is_ok());
}
#[test]
fn apply_configs_dry_run_covers_advanced_preset() {
- assert!(apply_configs(ADVANCED, true).is_ok());
+ assert!(apply_configs(ADVANCED, true, ConfigScope::Global).is_ok());
}
#[test]
fn apply_configs_dry_run_covers_delta_preset() {
- assert!(apply_configs(DELTA_CONFIGS, true).is_ok());
+ assert!(apply_configs(DELTA_CONFIGS, true, ConfigScope::Global).is_ok());
+ }
+
+ #[test]
+ fn determine_scope_defaults_to_global_outside_repo() {
+ let scope = determine_scope(false, false);
+ assert!(matches!(scope, ConfigScope::Global | ConfigScope::Local));
+ }
+
+ #[test]
+ fn determine_scope_respects_explicit_flags() {
+ assert!(matches!(determine_scope(true, false), ConfigScope::Global));
+ assert!(matches!(determine_scope(false, true), ConfigScope::Local));
+ }
+
+ #[test]
+ fn scope_flag_returns_correct_values() {
+ assert_eq!(scope_flag(ConfigScope::Global), "--global");
+ assert_eq!(scope_flag(ConfigScope::Local), "--local");
}
}
diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs
index 2a983f0..e2c0be9 100644
--- a/src/hooks/mod.rs
+++ b/src/hooks/mod.rs
@@ -81,6 +81,14 @@ pub(crate) fn valid_hook_names() -> &'static [&'static str] {
VALID_HOOKS
}
+/// Identifies which built-in (if any) an installed hook file corresponds to,
+/// by exact script comparison. Built-ins are written verbatim on install.
+pub(crate) fn detect_builtin(hook_file: &str, content: &str) -> Option<&'static builtins::Builtin> {
+ builtins::ALL
+ .iter()
+ .find(|b| b.hook == hook_file && content.trim() == b.script.trim())
+}
+
fn hooks_dir() -> Result {
Ok(find_repo_root()?.join(".git").join("hooks"))
}
@@ -211,22 +219,14 @@ fn list(available: bool) -> Result<()> {
Ok(())
}
-fn remove(hook: &str, yes: bool, dry_run: bool) -> Result<()> {
+fn remove(hook: &str, yes: bool, _dry_run: bool) -> Result<()> {
+ remove_hook(hook, yes)
+}
+
+pub(crate) fn remove_hook(hook: &str, _yes: 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(())
}
@@ -302,4 +302,25 @@ mod tests {
let err = resolve_hook("unknown-builtin", None).unwrap_err();
assert!(err.to_string().contains("not a built-in"));
}
+
+ #[test]
+ fn detect_builtin_distinguishes_builtins_sharing_a_hook_file() {
+ let no_secrets = builtins::get("no-secrets").unwrap();
+ let branch_naming = builtins::get("branch-naming").unwrap();
+ assert_eq!(
+ detect_builtin("pre-commit", no_secrets.script).map(|b| b.name),
+ Some("no-secrets")
+ );
+ assert_eq!(
+ detect_builtin("pre-commit", branch_naming.script).map(|b| b.name),
+ Some("branch-naming")
+ );
+ }
+
+ #[test]
+ fn detect_builtin_rejects_custom_scripts_and_wrong_hook() {
+ assert!(detect_builtin("pre-commit", "#!/bin/sh\nset -e\ncargo test\n").is_none());
+ let no_secrets = builtins::get("no-secrets").unwrap();
+ assert!(detect_builtin("commit-msg", no_secrets.script).is_none());
+ }
}
diff --git a/src/init.rs b/src/init.rs
index 6298e33..b22767c 100644
--- a/src/init.rs
+++ b/src/init.rs
@@ -1,7 +1,8 @@
use anyhow::Result;
-use inquire::{MultiSelect, Text};
+use inquire::{MultiSelect, Select, Text};
+use std::{collections::HashSet, fs};
-use crate::{attributes, config, git, hooks, ignore};
+use crate::{attributes, builds, config, git, hooks, ignore, utils::find_repo_root};
const BANNER: &str = r#"
███ █████ █████ ███ █████
@@ -27,6 +28,29 @@ pub fn run() -> Result<()> {
println!("{BANNER}");
println!(" Configure your git repo\n");
+ // ── Build selection ─────────────────────────────────────────────────────
+ let saved_builds = builds::list_build_names();
+ if !saved_builds.is_empty() {
+ let mut options = vec!["Start fresh configuration".to_string()];
+ options.extend(saved_builds.iter().map(|b| format!("Use build: {b}")));
+
+ let choice = Select::new("Saved builds available", options)
+ .with_help_message("↑↓ move enter confirm esc start fresh")
+ .prompt_skippable()?;
+
+ if let Some(build_name) = choice
+ .as_deref()
+ .and_then(|c| c.strip_prefix("Use build: "))
+ {
+ println!();
+ let build = builds::load_build(build_name)?;
+ builds::apply_build(&build)?;
+ println!("\n Done\n");
+ return Ok(());
+ }
+ println!();
+ }
+
let cargo_available = std::process::Command::new("cargo")
.arg("--version")
.output()
@@ -35,14 +59,36 @@ pub fn run() -> Result<()> {
// ── Hooks ────────────────────────────────────────────────────────────────
let builtins = hooks::available_builtins();
+ let installed_hooks = get_installed_hooks();
+
let mut hook_items: Vec = builtins
.iter()
- .map(|b| format!("{:<25} ({}) — {}", b.name, b.hook, b.description))
+ .map(|b| {
+ let base = format!("{:<25} ({}) — {}", b.name, b.hook, b.description);
+ if installed_hooks.contains(b.name) {
+ format!("{} [✓ installed]", base)
+ } else {
+ base
+ }
+ })
.collect();
hook_items.push("Add custom hook...".to_string());
+ let preselected: Vec = builtins
+ .iter()
+ .enumerate()
+ .filter(|(_, b)| installed_hooks.contains(b.name))
+ .map(|(i, _)| i)
+ .collect();
+
+ let default_selection = if preselected.is_empty() {
+ vec![0usize]
+ } else {
+ preselected
+ };
+
let hook_selections = MultiSelect::new("Hooks", hook_items.clone())
- .with_default(&[0usize]) // conventional-commits preselected
+ .with_default(&default_selection)
.with_help_message("↑↓ move space select enter confirm esc skip")
.prompt_skippable()?
.unwrap_or_default();
@@ -52,12 +98,18 @@ pub fn run() -> Result<()> {
for item in &hook_selections {
if item == "Add custom hook..." {
- let valid = hooks::valid_hook_names().join(", ");
- let hook_name = Text::new(" Hook name")
- .with_help_message(&format!("Valid: {valid}"))
- .prompt()?;
- let command = Text::new(" Command to run").prompt()?;
- custom_hooks.push((hook_name, command));
+ let Some(hook_name) =
+ Select::new("Hook type", hooks::valid_hook_names().to_vec()).prompt_skippable()?
+ else {
+ continue;
+ };
+ let command = Text::new(" Command to run")
+ .prompt_skippable()?
+ .unwrap_or_default();
+ if command.trim().is_empty() {
+ continue;
+ }
+ custom_hooks.push((hook_name.to_string(), command));
} else if let Some(idx) = hook_items.iter().position(|i| i == item) {
if idx < builtins.len() {
selected_builtins.push(builtins[idx].name);
@@ -65,6 +117,12 @@ pub fn run() -> Result<()> {
}
}
+ let hooks_to_remove: Vec<&str> = installed_hooks
+ .iter()
+ .filter(|h| !selected_builtins.contains(&h.as_str()))
+ .map(|s| s.as_str())
+ .collect();
+
// ── .gitignore ───────────────────────────────────────────────────────────
println!();
let all_templates = load_ignore_templates();
@@ -97,22 +155,34 @@ pub fn run() -> Result<()> {
// ── Git config ───────────────────────────────────────────────────────────
println!();
+ let configured_keys = get_configured_keys();
+
let config_options: Vec<&config::ConfigOption> = config::CONFIG_OPTIONS
.iter()
.filter(|o| o.key != "core.pager" || cargo_available)
.collect();
- let config_labels: Vec<&str> = config_options.iter().map(|o| o.label).collect();
+ let config_labels: Vec = config_options
+ .iter()
+ .map(|o| {
+ if configured_keys.contains(o.key) {
+ format!("{} [✓ already set]", o.label)
+ } else {
+ o.label.to_string()
+ }
+ })
+ .collect();
+
+ let config_labels_refs: Vec<&str> = config_labels.iter().map(|s| s.as_str()).collect();
- // pre-select recommended ones
let defaults: Vec = config_options
.iter()
.enumerate()
- .filter(|(_, o)| o.recommended)
+ .filter(|(_, o)| o.recommended || configured_keys.contains(o.key))
.map(|(i, _)| i)
.collect();
- let config_selections = MultiSelect::new("Git config", config_labels.clone())
+ let config_selections = MultiSelect::new("Git config", config_labels_refs.clone())
.with_default(&defaults)
.with_help_message("↑↓ move space select enter confirm esc skip")
.prompt_skippable()?
@@ -120,16 +190,24 @@ pub fn run() -> Result<()> {
let selected_config_keys: Vec<&str> = resolve_keys(
&config_selections,
- &config_labels,
+ &config_labels_refs,
&config_options.iter().map(|o| o.key).collect::>(),
);
+ let configs_to_remove: Vec<&str> = config_options
+ .iter()
+ .filter(|o| configured_keys.contains(o.key) && !selected_config_keys.contains(&o.key))
+ .map(|o| o.key)
+ .collect();
+
// ── Summary & confirm ────────────────────────────────────────────────────
+ let has_removals = !hooks_to_remove.is_empty() || !configs_to_remove.is_empty();
let nothing = selected_builtins.is_empty()
&& custom_hooks.is_empty()
&& selected_templates.is_empty()
&& selected_attrs.is_empty()
- && selected_config_keys.is_empty();
+ && selected_config_keys.is_empty()
+ && !has_removals;
if nothing {
println!("\n Nothing selected — exiting.");
@@ -175,6 +253,23 @@ pub fn run() -> Result<()> {
hooks::install_custom(hook, cmd, false)?;
println!(" ◇ hook '{hook}' installed ✓");
}
+ for hook in &hooks_to_remove {
+ if let Some(builtin) = hooks::available_builtins().iter().find(|b| b.name == *hook) {
+ // Several built-ins can share a hook file (e.g. pre-commit); don't
+ // delete the file if a freshly installed selection now owns it.
+ let file_reused = selected_builtins.iter().any(|sel| {
+ hooks::available_builtins()
+ .iter()
+ .any(|b| b.name == *sel && b.hook == builtin.hook)
+ });
+ if file_reused {
+ continue;
+ }
+ if hooks::remove_hook(builtin.hook, true).is_ok() {
+ println!(" ◇ hook '{hook}' removed ✓");
+ }
+ }
+ }
if !selected_templates.is_empty() {
let joined = selected_templates.join(",");
ignore::add_templates(&joined, false)?;
@@ -185,9 +280,45 @@ pub fn run() -> Result<()> {
println!(" ◇ .gitattributes applied ✓");
}
if !selected_config_keys.is_empty() {
- config::apply_config_keys(&selected_config_keys, cargo_available)?;
+ config::apply_config_keys(
+ &selected_config_keys,
+ cargo_available,
+ config::ConfigScope::Local,
+ )?;
println!(" ◇ git config applied ✓");
}
+ // Only touch the repo's local config; a global value affects every repo,
+ // so it is never removed from here.
+ for key in &configs_to_remove {
+ if config::remove_config_key(key, config::ConfigScope::Local).is_ok() {
+ println!(" ◇ git config '{key}' removed ✓");
+ } else {
+ println!(
+ " ◇ git config '{key}' is set globally — left untouched (git config --global --unset {key})"
+ );
+ }
+ }
+
+ // ── Save as build ─────────────────────────────────────────────────────
+ println!();
+ let save_build = inquire::Confirm::new("Save this configuration as a reusable build?")
+ .with_default(false)
+ .prompt()?;
+
+ if save_build {
+ let name = Text::new(" Build name").prompt()?;
+ let description = Text::new(" Description (optional)")
+ .with_default("")
+ .prompt()?;
+ let desc_ref = if description.is_empty() {
+ None
+ } else {
+ Some(description.as_str())
+ };
+ if let Err(e) = builds::save(&name, desc_ref) {
+ println!(" ⚠ Failed to save build: {e}");
+ }
+ }
println!("\n Done\n");
Ok(())
@@ -197,6 +328,73 @@ fn load_ignore_templates() -> Vec {
ignore::fetch_template_list().unwrap_or_default()
}
+fn get_installed_hooks() -> HashSet {
+ let Ok(root) = find_repo_root() else {
+ return HashSet::new();
+ };
+ let hooks_dir = root.join(".git").join("hooks");
+ if !hooks_dir.exists() {
+ return HashSet::new();
+ }
+ let Ok(entries) = fs::read_dir(&hooks_dir) else {
+ return HashSet::new();
+ };
+ entries
+ .filter_map(|e| e.ok())
+ .filter(|e| {
+ let name = e.file_name().to_string_lossy().to_string();
+ !name.ends_with(".bak") && !name.ends_with(".sample")
+ })
+ .filter_map(|e| {
+ let name = e.file_name().to_string_lossy().to_string();
+ let content = fs::read_to_string(e.path()).unwrap_or_default();
+ hooks::detect_builtin(&name, &content).map(|b| b.name.to_string())
+ })
+ .collect()
+}
+
+fn get_configured_keys() -> HashSet {
+ let mut configured = HashSet::new();
+
+ // Get all config values in one call per scope
+ let local_configs = get_all_git_configs("--local");
+ let global_configs = get_all_git_configs("--global");
+
+ for option in config::CONFIG_OPTIONS {
+ if option.key == "core.pager" {
+ continue;
+ }
+ if let Some(expected_value) = option.value {
+ // Check local first, then global
+ if local_configs.get(option.key).map(|s| s.as_str()) == Some(expected_value)
+ || global_configs.get(option.key).map(|s| s.as_str()) == Some(expected_value)
+ {
+ configured.insert(option.key.to_string());
+ }
+ }
+ }
+ configured
+}
+
+fn get_all_git_configs(scope: &str) -> std::collections::HashMap {
+ let Ok(output) = std::process::Command::new("git")
+ .args(["config", scope, "--list"])
+ .output()
+ else {
+ return std::collections::HashMap::new();
+ };
+ if !output.status.success() {
+ return std::collections::HashMap::new();
+ }
+ String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .filter_map(|line| {
+ line.split_once('=')
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ })
+ .collect()
+}
+
/// Maps selected display labels back to their corresponding keys.
fn resolve_keys<'a>(
selections: &[impl AsRef],
@@ -213,3 +411,25 @@ fn resolve_keys<'a>(
})
.collect()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn get_configured_keys_only_returns_known_option_keys() {
+ let configured = get_configured_keys();
+ for key in &configured {
+ assert!(config::CONFIG_OPTIONS.iter().any(|o| o.key == key));
+ }
+ }
+
+ #[test]
+ fn resolve_keys_maps_labels_to_keys() {
+ let selections = vec!["option A", "option C"];
+ let labels = vec!["option A", "option B", "option C"];
+ let keys = vec!["key_a", "key_b", "key_c"];
+ let result = resolve_keys(&selections, &labels, &keys);
+ assert_eq!(result, vec!["key_a", "key_c"]);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 55ddc74..c6d30fc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,12 +2,14 @@ use anyhow::Result;
use clap::{Parser, Subcommand};
mod attributes;
+mod builds;
mod clone;
mod config;
mod git;
mod hooks;
mod ignore;
mod init;
+mod status;
mod utils;
#[derive(Parser)]
@@ -18,13 +20,15 @@ mod utils;
)]
struct Cli {
#[command(subcommand)]
- command: Command,
+ command: Option,
}
#[derive(Subcommand)]
enum Command {
/// Interactive wizard to configure your repo
Init,
+ /// Show current configuration status
+ Status,
/// Clone repository and run init wizard
Clone(clone::CloneArgs),
/// Manage git hooks
@@ -47,16 +51,23 @@ enum Command {
#[command(subcommand)]
action: config::ConfigCommand,
},
+ /// Manage saved builds
+ Build {
+ #[command(subcommand)]
+ action: builds::BuildCommand,
+ },
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
- Command::Init => init::run(),
- Command::Clone(args) => clone::run(args),
- Command::Hooks { action } => hooks::run(action),
- Command::Ignore { action } => ignore::run(action),
- Command::Attributes { action } => attributes::run(action),
- Command::Config { action } => config::run(action),
+ Some(Command::Init) | None => init::run(),
+ Some(Command::Status) => status::run(),
+ Some(Command::Clone(args)) => clone::run(args),
+ Some(Command::Hooks { action }) => hooks::run(action),
+ Some(Command::Ignore { action }) => ignore::run(action),
+ Some(Command::Attributes { action }) => attributes::run(action),
+ Some(Command::Config { action }) => config::run(action),
+ Some(Command::Build { action }) => builds::run(action),
}
}
diff --git a/src/status/mod.rs b/src/status/mod.rs
new file mode 100644
index 0000000..dec677d
--- /dev/null
+++ b/src/status/mod.rs
@@ -0,0 +1,159 @@
+use anyhow::Result;
+use std::fs;
+
+use crate::utils::{find_repo_root, git_config_get};
+
+pub fn run() -> Result<()> {
+ let in_repo = find_repo_root().is_ok();
+
+ if in_repo {
+ print_hooks()?;
+ println!();
+ print_gitignore()?;
+ println!();
+ print_gitattributes()?;
+ println!();
+ print_config("local")?;
+ println!();
+ }
+
+ print_config("global")?;
+
+ Ok(())
+}
+
+fn print_hooks() -> Result<()> {
+ println!("Hooks:");
+
+ let root = find_repo_root()?;
+ let hooks_dir = root.join(".git").join("hooks");
+
+ if !hooks_dir.exists() {
+ println!(" (none)");
+ return Ok(());
+ }
+
+ let installed: Vec<_> = fs::read_dir(&hooks_dir)?
+ .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")
+ })
+ .collect();
+
+ if installed.is_empty() {
+ println!(" (none)");
+ return Ok(());
+ }
+
+ for entry in installed {
+ let hook_name = entry.file_name().to_string_lossy().to_string();
+ let content = fs::read_to_string(entry.path()).unwrap_or_default();
+
+ match crate::hooks::detect_builtin(&hook_name, &content) {
+ Some(b) => println!(" ✓ {} ({})", b.name, b.hook),
+ None => {
+ let first_cmd = content
+ .lines()
+ .find(|l| !l.starts_with('#') && !l.starts_with("set ") && !l.trim().is_empty())
+ .unwrap_or("(custom)")
+ .trim();
+ println!(" ✓ custom: {} → {:?}", hook_name, first_cmd);
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn print_gitignore() -> Result<()> {
+ println!(".gitignore:");
+
+ let root = find_repo_root()?;
+ let path = root.join(".gitignore");
+
+ if !path.exists() {
+ println!(" (none)");
+ return Ok(());
+ }
+
+ let content = fs::read_to_string(&path)?;
+ let patterns: Vec<_> = content
+ .lines()
+ .filter(|l| !l.is_empty() && !l.starts_with('#'))
+ .collect();
+
+ println!(" ✓ {} patterns", patterns.len());
+ Ok(())
+}
+
+fn print_gitattributes() -> Result<()> {
+ println!(".gitattributes:");
+
+ let root = find_repo_root()?;
+ let path = root.join(".gitattributes");
+
+ if !path.exists() {
+ println!(" (none)");
+ return Ok(());
+ }
+
+ let content = fs::read_to_string(&path)?;
+ let mut presets = Vec::new();
+
+ if content.contains("eol=lf") {
+ presets.push("line-endings (eol=lf)");
+ }
+ if content.contains("binary") {
+ presets.push("binary-files");
+ }
+
+ if presets.is_empty() {
+ println!(" ✓ custom");
+ } else {
+ println!(" ✓ {}", presets.join(", "));
+ }
+
+ Ok(())
+}
+
+fn print_config(scope: &str) -> Result<()> {
+ let label = if scope == "global" {
+ "Git config (global)"
+ } else {
+ "Git config (local)"
+ };
+ println!("{label}:");
+
+ let scope_flag = if scope == "global" {
+ "--global"
+ } else {
+ "--local"
+ };
+
+ let mut any = false;
+ for option in crate::config::CONFIG_OPTIONS {
+ if let Some(value) = git_config_get(option.key, scope_flag) {
+ println!(" ✓ {} = {value}", option.key);
+ any = true;
+ }
+ }
+
+ if !any {
+ println!(" (none)");
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::utils::git_config_get;
+
+ #[test]
+ fn git_config_get_returns_none_for_missing_key() {
+ let result = git_config_get("nonexistent.key.xyz", "--global");
+ assert!(result.is_none());
+ }
+}
diff --git a/src/utils.rs b/src/utils.rs
index 7e820e2..5a3070f 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
+use std::process::Command;
/// Walk up from CWD until we find a `.git` directory, like git itself does.
pub(crate) fn find_repo_root() -> Result {
@@ -26,6 +27,20 @@ pub(crate) fn confirm(prompt: &str, yes: bool) -> bool {
matches!(input.trim(), "y" | "Y")
}
+/// Get a git config value for a specific key and scope (--global or --local).
+pub(crate) fn git_config_get(key: &str, scope: &str) -> Option {
+ let output = Command::new("git")
+ .args(["config", scope, "--get", key])
+ .output()
+ .ok()?;
+
+ if output.status.success() {
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ } else {
+ None
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -47,4 +62,10 @@ mod tests {
fn confirm_returns_true_when_yes_flag_set() {
assert!(confirm("anything?", true));
}
+
+ #[test]
+ fn git_config_get_returns_none_for_missing_key() {
+ let result = git_config_get("nonexistent.key.xyz", "--global");
+ assert!(result.is_none());
+ }
}