diff --git a/README.md b/README.md index 8904a49..75c0ff4 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,15 @@ - Provide a terminal UI for reviewing commits and changes interactively. - Generate Markdown in strict [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## Design Overview +### Architecture -### Core Packages +- **Git integration:** Uses `go-git` for commit history and tag resolution — no shell calls. +- **Diffing:** Custom lightweight diff engine for readable line-by-line output. +- **Unreleased storage:** Simple Markdown files with YAML frontmatter (no external formats). +- **Interactive mode:** Bubble Tea model for categorizing and confirming changes. +- **Output:** Always produces Keep a Changelog–compliant Markdown. + +## Core Packages ```sh . @@ -55,43 +61,35 @@ storm release --version 1.3.0 [--tag] Merges `.changes/*.md` into the changelog, writes a new section, and optionally tags the repository. -## Architecture - -- **Git integration:** Uses `go-git` for commit history and tag resolution — no shell calls. -- **Diffing:** Custom lightweight diff engine for readable line-by-line output. -- **Unreleased storage:** Simple Markdown files with YAML frontmatter (no external formats). -- **Interactive mode:** Bubble Tea model for categorizing and confirming changes. -- **Output:** Always produces Keep a Changelog–compliant Markdown. - ## Development Guidance 1. Composable Each subsystem (`diff`, `gitlog`, `tui`, etc.) should work standalone and be callable from tests or other Go programs. 2. Frontmatter - ```yaml - type: added - scope: cli - summary: Add changelog command - ``` + ```yaml + type: added + scope: cli + summary: Add changelog command + ``` 3. Consistent Palette - | Type | Color | - | -------- | --------- | - | Added | `#10b981` | - | Changed | `#0ea5e9` | - | Fixed | `#f43f5e` | - | Removed | `#f59e0b` | - | Security | `#9333ea` | + | Type | Color | + | -------- | --------- | + | Added | `#10b981` | + | Changed | `#0ea5e9` | + | Fixed | `#f43f5e` | + | Removed | `#f59e0b` | + | Security | `#9333ea` | 4. Commands should chain naturally and script cleanly: - ```sh - storm unreleased list --json - storm generate --since v1.2.0 --interactive - storm release --version 1.3.0 - ``` + ```sh + storm unreleased list --json + storm generate --since v1.2.0 --interactive + storm release --version 1.3.0 + ``` 5. Tests - Research testing bubbletea programs @@ -115,3 +113,63 @@ Merges `.changes/*.md` into the changelog, writes a new section, and optionally - No external dependencies beyond `cobra`, `go-git`, `bubbletea`, `lipgloss`, and `yaml.v3`. - Keep the workflow simple and reproducible so changelogs can be deterministically derived from local data. - Make sure interactive sessions degrade gracefully in non-TTY environments. + +## Conventional Commits + +### Structure + +| Element | Format | Description | +| ------------------------- | ---------------------------------------- | ---------------------------------------- | +| Header | `(): ` | The main commit message line. | +| Scope | Optional, e.g. `api`, `cli`, `deps` | Indicates part of the codebase affected. | +| Breaking Change Indicator | `!` after type/scope, e.g. `feat(api)!:` | Marks a breaking API change. | +| Body | (Optional) one blank line then body text | Explanation of what & why. | +| Footer | (Optional) one blank line then meta info | Issue refs, `BREAKING CHANGE: …`, etc | + +### Types + +| Type | Description | +| ---------- | ----------------------------------------------------------------------- | +| `feat` | A new feature. | +| `fix` | A bug fix. | +| `docs` | Documentation only changes. | +| `style` | Code style changes (formatting, whitespace) that don’t affect behavior. | +| `refactor` | Code changes that neither fix a bug nor add a feature. | +| `perf` | Performance improvements. | +| `test` | Adding or updating tests. | +| `build` | Changes that affect the build system or dependencies. | +| `ci` | Changes to CI configuration and scripts. | +| `chore` | Other changes that don’t touch src/test (e.g., tooling, config). | +| `revert` | Reverts a previous commit. | + +### Examples + +```text +feat(api): add pagination endpoint + +fix(ui): correct button alignment issue + +docs: update README installation instructions + +perf(core): optimize user query performance + +refactor: restructure payment module for clarity + +style: apply consistent formatting + +test(auth): add integration tests for OAuth flow + +build(deps): bump dependencies to latest versions + +ci: add GitHub Actions workflow for CI + +chore: update .gitignore and clean up obsolete files + +feat(api)!: remove support for legacy endpoints + +BREAKING CHANGE: API no longer accepts XML-formatted requests. +``` + +### Reference + + "Conventional Commits" diff --git a/go.mod b/go.mod index 141125f..9c86891 100644 --- a/go.mod +++ b/go.mod @@ -7,26 +7,36 @@ require ( github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v0.4.2 github.com/go-git/go-git/v6 v6.0.0-20251103200709-47b1ed2930c9 - github.com/spf13/cobra v1.9.1 + github.com/spf13/cobra v1.10.1 +) + +require ( + github.com/clipperhouse/displaywidth v0.4.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/colorprofile v0.3.3 // indirect github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea // indirect github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/ansi v0.10.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/exp/teatest v0.0.0-20251103210727-681bf553bc2e + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.5.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20251022185412-61e52df296a5 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect @@ -36,7 +46,9 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect @@ -46,7 +58,7 @@ require ( github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.4.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect diff --git a/go.sum b/go.sum index 5199c07..2a8ae61 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +8,12 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= -github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= @@ -22,20 +24,28 @@ github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsy github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M= github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.10.3 h1:3WoV9XN8uMEnFRZZ+vBPRy59TaIWa+gJodS4Vg5Fut0= +github.com/charmbracelet/x/ansi v0.10.3/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251103210727-681bf553bc2e h1:8hEJFkBwIvEVQTfwsFF3fmCJSqibJT7NvQsGLbZzfkU= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251103210727-681bf553bc2e/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= +github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -48,6 +58,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= @@ -75,8 +87,12 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -93,16 +109,15 @@ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -117,6 +132,7 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/diff/tools.go b/internal/diff/tools.go new file mode 100644 index 0000000..5a5a4f7 --- /dev/null +++ b/internal/diff/tools.go @@ -0,0 +1,77 @@ +package diff + +import "io" + +// DiffViewKind enumerates the supported diff views. +type DiffViewKind int + +const ( + ViewUnified DiffViewKind = iota + ViewSplit + ViewHunk + ViewInline + ViewRich + ViewSource +) + +// String returns a human-readable name for the view kind. +func (k DiffViewKind) String() string { + switch k { + case ViewUnified: + return "Unified" + case ViewSplit: + return "Split" + case ViewHunk: + return "Hunk" + case ViewInline: + return "Inline" + case ViewRich: + return "Rich" + case ViewSource: + return "Source" + default: + return "Unknown" + } +} + +// DiffResult holds the output of a diff tool. +type DiffResult struct { + // Content is the rendered diff output (text, ANSI, HTML, etc.) + Content string + + // View is the kind of view used to render the diff. + View DiffViewKind + + // Metadata like file paths, commit hashes, timestamps (optional). + // You may extend as needed. + OldPath string + NewPath string + FromHash string + ToHash string +} + +// DiffTool is the interface for generating diffs between two versions. +type DiffTool interface { + // Diff takes two blobs (old and new versions) and returns a DiffResult. + // The reader parameters may be full file contents or other abstraction. + // The viewKind parameter selects which view implementation (Unified/Split/...). + Diff(oldContent io.Reader, newContent io.Reader, viewKind DiffViewKind) (DiffResult, error) +} + +// UnifiedDiff implements unified view (single linear view with additions & deletions). +type UnifiedDiff struct{} + +// SplitDiff implements side-by-side view (old on left, new on right). +type SplitDiff struct{} + +// HunkDiff focuses on changed blocks, minimal context. +type HunkDiff struct{} + +// InlineDiff renders changes inline in full file flow. +type InlineDiff struct{} + +// RichDiff renders changes for formatted preview (for example, markdown or html previews). +type RichDiff struct{} + +// SourceDiff simply returns raw diff data or patch format without special formatting. +type SourceDiff struct{} diff --git a/internal/gitlog/gitlog.go b/internal/gitlog/gitlog.go new file mode 100644 index 0000000..d136ff7 --- /dev/null +++ b/internal/gitlog/gitlog.go @@ -0,0 +1,88 @@ +package gitlog + +import ( + "fmt" + "time" + + "github.com/go-git/go-git/v6" +) + +// CommitKind represents the kind of commit according to Conventional Commits. +type CommitKind int + +const ( + CommitTypeUnknown CommitKind = iota + CommitTypeFeat + CommitTypeFix + CommitTypeDocs + CommitTypeStyle + CommitTypeRefactor + CommitTypePerf + CommitTypeTest + CommitTypeBuild + CommitTypeCI + CommitTypeChore + CommitTypeRevert +) + +// String returns the string representation of the CommitType. +func (kind CommitKind) String() string { + switch kind { + case CommitTypeFeat: + return "feat" + case CommitTypeFix: + return "fix" + case CommitTypeDocs: + return "docs" + case CommitTypeStyle: + return "style" + case CommitTypeRefactor: + return "refactor" + case CommitTypePerf: + return "perf" + case CommitTypeTest: + return "test" + case CommitTypeBuild: + return "build" + case CommitTypeCI: + return "ci" + case CommitTypeChore: + return "chore" + case CommitTypeRevert: + return "revert" + default: + return "unknown" + } +} + +func Log() { fmt.Println(git.GitDirName) } + +type CommitMeta struct { + Type string // feat, fix, docs, etc. + Scope string // optional + Description string + Breaking bool + Body string + Footers map[string]string +} + +// CommitParser defines parsing of raw commit message strings into structured metadata. +type CommitParser interface { + // Parse takes a commit hash, subject line, body (including footers) + // and the commit date, and returns a structured [CommitMeta] + Parse(hash, subject, body string, date time.Time) (CommitMeta, error) + + // IsValidType returns true if the given [CommitKind] is recognised / allowed by your tooling. + IsValidType(kind CommitKind) bool + + // Categorize returns the category (e.g., "Added", "Fixed", "Changed") for the given CommitMeta. + Categorize(meta CommitMeta) string +} + +// DefaultParser implements [CommitParser] and parses single +// or multi-line commits into one or more [CommitMeta] +type DefaultParser struct{} + +// ConventionalParser implements [CommitParser] and parses +// conventional commits into one or more [CommitMeta] +type ConventionalParser struct{} diff --git a/internal/style/style.go b/internal/style/style.go new file mode 100644 index 0000000..74e5a3a --- /dev/null +++ b/internal/style/style.go @@ -0,0 +1,61 @@ +// package styles define a color palette from nord & iceberg.vim +package style + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +var ( + Background = lipgloss.Color("#1B1F27") // dark slate blue/charcoal + Foreground = lipgloss.Color("#D8DEE9") // light grey-white for readable text + AccentBlue = lipgloss.Color("#71A6D2") // main iceberg blue + AccentSteel = lipgloss.Color("#4484B4") // steel blue + AccentLapis = lipgloss.Color("#1B5E98") // lapis lazuli + AccentCerulean = lipgloss.Color("#074683") // dark cerulean + AddedColor = lipgloss.Color("#a3be8c") // nord aurora + ChangedColor = AccentSteel + FixedColor = AccentCerulean + RemovedColor = lipgloss.Color("#BF616A") // nord aurora + SecurityColor = lipgloss.Color("#d08770") // nord aurora +) + +var ( + StyleHeadline = fgColor(AccentBlue).Bold(true) + StyleText = fgColor(Foreground).Background(Background) + StyleAdded = fgColor(AddedColor) + StyleChanged = fgColor(ChangedColor) + StyleFixed = fgColor(FixedColor) + StyleRemoved = fgColor(RemovedColor) + StyleSecurity = fgColor(SecurityColor) +) + +func fgColor(c lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } + +func Headline(s string) { + v := StyleHeadline.Render(s) + fmt.Println(v) +} + +func Added(s string) { + v := StyleAdded.Render(s) + fmt.Println(v) +} + +func Fixed(s string) { + v := StyleFixed.Render(s) + fmt.Println(v) +} + +func Styled(st lipgloss.Style) func(s string, a ...any) { + return func(s string, a ...any) { + fmt.Printf(s, a...) + } +} + +func Styledln(st lipgloss.Style) func(s string, a ...any) { + return func(s string, a ...any) { + fmt.Println(fmt.Sprintf(s, a...)) + } +} diff --git a/internal/testutils/assertions.go b/internal/testutils/assertions.go new file mode 100644 index 0000000..15f2440 --- /dev/null +++ b/internal/testutils/assertions.go @@ -0,0 +1,147 @@ +// package testutils contains assertions with struct [expect] +// +// Adapted from https://www.alexedwards.net/blog/the-9-go-test-assertions-i-use +package testutils + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "testing" +) + +type expect struct{} + +// Expect is the exported instance users call. +var Expect = expect{} + +// Equal asserts that got and want are equal (using reflect.DeepEqual where necessary). +func (e expect) Equal(t *testing.T, got, want any, args ...any) { + t.Helper() + if !isDeepEqual(got, want) { + t.Errorf("Equal assertion failed: got %#v; want %#v", got, want) + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// NotEqual asserts that got and want are *not* equal. +func (e expect) NotEqual(t *testing.T, got, want any, args ...any) { + t.Helper() + if isDeepEqual(got, want) { + t.Errorf("NotEqual assertion failed: got %#v; expected different value", got) + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// True asserts that the boolean got is true. +func (e expect) True(t *testing.T, got bool, args ...any) { + t.Helper() + if !got { + t.Errorf("True assertion failed: got false; want true") + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// False asserts that the boolean got is false. +func (e expect) False(t *testing.T, got bool, args ...any) { + t.Helper() + if got { + t.Errorf("False assertion failed: got true; want false") + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// Nil asserts that got is nil. +func (e expect) Nil(t *testing.T, got any, args ...any) { + t.Helper() + if !isNil(got) { + t.Errorf("Nil assertion failed: got non-nil value %#v", got) + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// NotNil asserts that got is *not* nil. +func (e expect) NotNil(t *testing.T, got any, args ...any) { + t.Helper() + if isNil(got) { + t.Errorf("NotNil assertion failed: got nil; want non-nil") + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// ErrorIs asserts that err wraps or is target. +func (e expect) ErrorIs(t *testing.T, err, target error, args ...any) { + t.Helper() + if !errors.Is(err, target) { + t.Errorf("ErrorIs assertion failed: got error %#v; want error matching %#v", err, target) + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// ErrorAs asserts that err can be assigned to target via [errors.As]. +func (e expect) ErrorAs(t *testing.T, err error, target any, args ...any) { + t.Helper() + if err == nil { + t.Errorf("ErrorAs assertion failed: got nil; want assignable to %T", target) + return + } + if !errors.As(err, &target) { + t.Errorf("ErrorAs assertion failed: got error %#v; want assignable to %T", err, target) + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// MatchesRegexp asserts that the string got matches the given regex pattern. +func (e expect) MatchesRegexp(t *testing.T, got, pattern string, args ...any) { + t.Helper() + matched, err := regexp.MatchString(pattern, got) + if err != nil { + t.Fatalf("MatchesRegexp assertion: invalid pattern %q: %v", pattern, err) + return + } + if !matched { + t.Errorf("MatchesRegexp assertion failed: got %#v; want to match pattern %q", got, pattern) + if len(args) > 0 { + t.Logf("Message: %v", fmt.Sprint(args...)) + } + } +} + +// isDeepEqual handles deep equality including nil checks. +func isDeepEqual(a, b any) bool { + if isNil(a) && isNil(b) { + return true + } + return reflect.DeepEqual(a, b) +} + +// isNil tests nil for interface, pointer, slice, map, chan, func types. +func isNil(v any) bool { + if v == nil { + return true + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.UnsafePointer: + return rv.IsNil() + default: + return false + } +} diff --git a/internal/testutils/tea.go b/internal/testutils/tea.go new file mode 100644 index 0000000..88e8416 --- /dev/null +++ b/internal/testutils/tea.go @@ -0,0 +1,50 @@ +package testutils + +import ( + "io" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" +) + +// WithTermSize adjust terminal size for tests +func WithTermSize(width, height int) teatest.TestOption { + return teatest.WithInitialTermSize(width, height) +} + +// RunModel runs a Bubble Tea model until it completes, and returns final output. +func RunModel(t *testing.T, m tea.Model, opts ...teatest.TestOption) io.Reader { + t.Helper() + tm := teatest.NewTestModel(t, m, opts...) + return tm.FinalOutput(t) +} + +// RunModelWithInteraction runs a model, sends messages, waits for finish, returns final model. +func RunModelWithInteraction(t *testing.T, m tea.Model, sendMsgs []tea.Msg, opts ...teatest.TestOption) tea.Model { + t.Helper() + tm := teatest.NewTestModel(t, m, opts...) + + for _, msg := range sendMsgs { + tm.Send(msg) + } + + tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) + return tm.FinalModel(t) +} + +// AssertModelField asserts a field on the final model using a selector function +func AssertModelField[T comparable](t *testing.T, finalModel tea.Model, fieldName string, getVal func(tea.Model) T, expected T) { + t.Helper() + actual := getVal(finalModel) + if actual != expected { + t.Errorf("model field %s: expected %v, got %v", fieldName, expected, actual) + } +} + +// WaitUntil waits for a condition on the output reader within timeout +func WaitUntil(t *testing.T, reader io.Reader, condition func([]byte) bool, timeout time.Duration) { + t.Helper() + teatest.WaitFor(t, reader, condition, teatest.WithDuration(timeout)) +} diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go new file mode 100644 index 0000000..f6b7f88 --- /dev/null +++ b/internal/testutils/testutils.go @@ -0,0 +1,128 @@ +package testutils + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// SetupTestRepo creates a git repository in a temporary directory with sample commits. +// The repository contains multiple commits with different types of changes to support +// testing diff algorithms, changelog generation, and git log parsing. +func SetupTestRepo(t *testing.T) *git.Repository { + t.Helper() + dir := t.TempDir() + + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + w, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + // Create commits with varied content for diff testing + commits := []struct { + name, content, message string + }{ + {"README.md", "# Project\n\nInitial version", "chore: initial commit"}, + {"a.txt", "hello world", "feat: add hello world"}, + {"b.txt", "fixed bug", "fix: patch file"}, + {"a.txt", "hello world\ngoodbye world", "feat: add goodbye"}, + {"c.txt", "new feature\nwith multiple lines\nof content", "feat: add multi-line file"}, + {"b.txt", "fixed bug\nwith proper handling", "fix: improve error handling"}, + } + + for _, c := range commits { + path := filepath.Join(dir, c.name) + if err := os.WriteFile(path, []byte(c.content), 0644); err != nil { + t.Fatalf("failed to write file %s: %v", c.name, err) + } + if _, err := w.Add(c.name); err != nil { + t.Fatalf("failed to add file %s: %v", c.name, err) + } + if _, err := w.Commit(c.message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test Author", + Email: "test@example.com", + When: time.Now(), + }, + }); err != nil { + t.Fatalf("commit failed: %v", err) + } + } + return repo +} + +// CreateTag creates a lightweight tag at the current HEAD of the repository. +func CreateTag(t *testing.T, repo *git.Repository, tagName string) { + t.Helper() + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + _, err = repo.CreateTag(tagName, head.Hash(), nil) + if err != nil { + t.Fatalf("failed to create tag %s: %v", tagName, err) + } +} + +// GetCommitHistory returns all commits in the repository from HEAD backwards. +func GetCommitHistory(t *testing.T, repo *git.Repository) []*object.Commit { + t.Helper() + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + commitIter, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + t.Fatalf("failed to get commit log: %v", err) + } + + var commits []*object.Commit + err = commitIter.ForEach(func(c *object.Commit) error { + commits = append(commits, c) + return nil + }) + if err != nil { + t.Fatalf("failed to iterate commits: %v", err) + } + + return commits +} + +// AddCommit adds a new commit to the repository with the given file changes. +func AddCommit(t *testing.T, repo *git.Repository, filename, content, message string) { + t.Helper() + w, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + path := filepath.Join(w.Filesystem.Root(), filename) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write file %s: %v", filename, err) + } + + if _, err := w.Add(filename); err != nil { + t.Fatalf("failed to add file %s: %v", filename, err) + } + + if _, err := w.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test Author", + Email: "test@example.com", + When: time.Now(), + }, + }); err != nil { + t.Fatalf("commit failed: %v", err) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..5b1faa2 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1 @@ +package ui