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 @@ ░░░░░░ ``` -[![CI](https://github.com/UniverLab/gitkit/actions/workflows/ci.yml/badge.svg)](https://github.com/UniverLab/gitkit/actions/workflows/ci.yml) -[![Release](https://github.com/UniverLab/gitkit/actions/workflows/release.yml/badge.svg)](https://github.com/UniverLab/gitkit/actions/workflows/release.yml) -[![Crates.io](https://img.shields.io/crates/v/gitkit)](https://crates.io/crates/gitkit) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +

+ CI + Crates.io + Status + 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()); + } }