add optional post-commit hook to auto close git-bug issues
feat(hooks): add bash commit-msg hook for git-bug integration fix(hooks): correct hook type to post-commit and fix regex refactor(hooks): rename hook file to post-commit.bash feat(hooks): add install-hook and uninstall-hook commands feat(issues): transform commit references to links in issue pages fix(issues): improve transformCommitRefs performance and correctness docs: add post-commit hook documentation feat(hooks): support bare repositories in install-hook/uninstall-hook commands
53 files changed,  +2097, -27
M Makefile
+1, -1
1@@ -39,7 +39,7 @@ test-site:
2 		--home-url "https://test.com/test" \
3 		--label testdata \
4 		--desc "pgit testing site - [link](https://yourmom.com)" \
5-		--theme "kanagawa-dragon" \
6+		--theme "github-dark" \
7 		--revs "main,branch-c" \
8 		--issues
9 .PHONY: test-site
M README.md
+57, -0
 1@@ -64,6 +64,63 @@ echo '<html><body><a href="/pico">pico</a><a href="/starfx">starfx</a></body></h
 2 rsync -rv ./public/ pgs.sh:/git
 3 ```
 4 
 5+## post-commit hook
 6+
 7+pgit includes a post-commit hook that automatically closes git-bug issues when
 8+you reference them in commit messages.
 9+
10+### Installation
11+
12+Install the hook in your repository:
13+
14+```bash
15+./pgit install-hook --repo=/path/to/your/repo
16+```
17+
18+### Usage
19+
20+Once installed, the hook will automatically:
21+
22+1. Parse your commit messages for issue references
23+2. Add a comment "Fixed in commit [hash]" to the referenced issue
24+3. Close the git-bug issue
25+
26+Supported keywords (case-insensitive):
27+- `fix`, `fixes`, `fixed`
28+- `close`, `closes`, `closed`
29+- `resolve`, `resolves`, `resolved`
30+
31+Example commit messages:
32+
33+```bash
34+git commit -m "fixes #872a52d - resolved the memory leak"
35+git commit -m "This commit closes #abc1234 and resolves #def5678"
36+```
37+
38+### How it works
39+
40+When you commit with an issue reference:
41+
42+1. The hook extracts the 7-character issue ID (e.g., `872a52d`)
43+2. It runs `git-bug bug comment new [ID] -m "Fixed in commit [hash]"`
44+3. It runs `git-bug bug close [ID]` to close the issue
45+
46+When pgit generates the static site:
47+
48+1. Issue comments are scanned for "Fixed in commit [hash]" patterns
49+2. These are transformed into clickable links to the commit pages
50+3. The full 40-character hash is linked, but displayed as a 7-character short hash
51+
52+### Uninstallation
53+
54+Remove the hook:
55+
56+```bash
57+./pgit uninstall-hook --repo=/path/to/your/repo
58+```
59+
60+This will restore any previous post-commit hook if one existed.
61+
62 ## inspiration
63 
64 This project was heavily inspired by
A docs/plans/2026-04-11-commit-msg-hook-for-git-bug.md
+1151, -0
   1@@ -0,0 +1,1151 @@
   2+# Git Commit Hook for git-bug Integration Implementation Plan
   3+
   4+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
   5+
   6+**Goal:** Create a git commit-msg hook that parses commit messages for git-bug issue references (e.g., "fixes #872a52d"), automatically comments on those issues, and closes them.
   7+
   8+**Architecture:** A Go-based commit-msg hook that reads the commit message from stdin/file, parses it for issue references using regex patterns, uses the git-bug Go library to find matching bugs by short ID, adds a comment with a link to the commit, and closes the bug. The hook runs after the commit message is written but before the commit is finalized.
   9+
  10+**Tech Stack:** Go, git-bug library (entities/bug, entities/identity, repository packages), regex pattern matching
  11+
  12+---
  13+
  14+## Overview
  15+
  16+This feature adds a `commit-msg` git hook that:
  17+1. Reads the commit message from the file passed as argument
  18+2. Parses for patterns like `fixes #872a52d`, `close #872a52d`, `fixed #872a52d`
  19+3. Extracts the 7-character hex issue ID after the `#`
  20+4. Looks up the git-bug issue by its short ID
  21+5. Adds a comment: "Fixed in commit [short-hash](/link/to/commit/in/pgit)"
  22+6. Changes the bug status to `closed`
  23+
  24+**Supported Keywords:**
  25+- `fix`, `fixes`, `fixed` - closes the issue
  26+- `close`, `closes`, `closed` - closes the issue  
  27+- `resolve`, `resolves`, `resolved` - closes the issue
  28+
  29+**Link Format:** The commit URL follows pgit's URL scheme: `{root-relative}commits/{full-commit-hash}.html`
  30+
  31+---
  32+
  33+## Task 1: Create Commit Message Parser
  34+
  35+**Files:**
  36+- Create: `hooks/commit_msg_parser.go`
  37+- Test: `hooks/commit_msg_parser_test.go`
  38+
  39+**Step 1: Write the failing test**
  40+
  41+```go
  42+package hooks
  43+
  44+import (
  45+	"testing"
  46+)
  47+
  48+func TestParseIssueReferences(t *testing.T) {
  49+	tests := []struct {
  50+		name     string
  51+		message  string
  52+		expected []IssueReference
  53+	}{
  54+		{
  55+			name:    "fixes keyword",
  56+			message: "fixes #872a52d - resolved the memory leak",
  57+			expected: []IssueReference{
  58+				{Keyword: "fixes", IssueID: "872a52d"},
  59+			},
  60+		},
  61+		{
  62+			name:    "close keyword",
  63+			message: "This will close #872a52d",
  64+			expected: []IssueReference{
  65+				{Keyword: "close", IssueID: "872a52d"},
  66+			},
  67+		},
  68+		{
  69+			name:    "multiple references",
  70+			message: "fixes #872a52d and resolves #abc1234",
  71+			expected: []IssueReference{
  72+				{Keyword: "fixes", IssueID: "872a52d"},
  73+				{Keyword: "resolves", IssueID: "abc1234"},
  74+			},
  75+		},
  76+		{
  77+			name:     "no references",
  78+			message:  "Just a regular commit message",
  79+			expected: nil,
  80+		},
  81+		{
  82+			name:    "fixed past tense",
  83+			message: "fixed #872a52d",
  84+			expected: []IssueReference{
  85+				{Keyword: "fixed", IssueID: "872a52d"},
  86+			},
  87+		},
  88+		{
  89+			name:    "closing keyword",
  90+			message: "Closing #872a52d - done",
  91+			expected: []IssueReference{
  92+				{Keyword: "Closing", IssueID: "872a52d"},
  93+			},
  94+		},
  95+	}
  96+
  97+	for _, tt := range tests {
  98+		t.Run(tt.name, func(t *testing.T) {
  99+			result := ParseIssueReferences(tt.message)
 100+			if len(result) != len(tt.expected) {
 101+				t.Errorf("expected %d references, got %d", len(tt.expected), len(result))
 102+			}
 103+			for i, ref := range result {
 104+				if ref.Keyword != tt.expected[i].Keyword || ref.IssueID != tt.expected[i].IssueID {
 105+					t.Errorf("expected %+v, got %+v", tt.expected[i], ref)
 106+				}
 107+			}
 108+		})
 109+	}
 110+}
 111+```
 112+
 113+**Step 2: Run test to verify it fails**
 114+
 115+Run: `go test ./hooks/...`
 116+
 117+Expected: FAIL with "undefined: ParseIssueReferences"
 118+
 119+**Step 3: Write minimal implementation**
 120+
 121+```go
 122+package hooks
 123+
 124+import (
 125+	"regexp"
 126+	"strings"
 127+)
 128+
 129+// IssueReference represents a parsed issue reference from a commit message
 130+type IssueReference struct {
 131+	Keyword string // e.g., "fixes", "close", "resolves"
 132+	IssueID string // 7-char hex ID like "872a52d"
 133+}
 134+
 135+// issueRefRegex matches patterns like:
 136+// - fixes #872a52d
 137+// - close #872a52d
 138+// - resolved #abc1234
 139+// - Fixes: #872a52d
 140+// Case-insensitive, allows multiple whitespace variations
 141+var issueRefRegex = regexp.MustCompile(`(?im)(fix(?:es|ed)?|clos(?:e|es|ed)?|resolv(?:e|es|ed)?)[:\s]+#([a-f0-9]{7})\b`)
 142+
 143+// ParseIssueReferences extracts all issue references from a commit message
 144+func ParseIssueReferences(message string) []IssueReference {
 145+	matches := issueRefRegex.FindAllStringSubmatch(message, -1)
 146+	if matches == nil {
 147+		return nil
 148+	}
 149+
 150+	var refs []IssueReference
 151+	for _, match := range matches {
 152+		if len(match) >= 3 {
 153+			refs = append(refs, IssueReference{
 154+				Keyword: strings.ToLower(match[1]),
 155+				IssueID: strings.ToLower(match[2]),
 156+			})
 157+		}
 158+	}
 159+	return refs
 160+}
 161+```
 162+
 163+**Step 4: Run test to verify it passes**
 164+
 165+Run: `go test ./hooks/...`
 166+
 167+Expected: PASS
 168+
 169+**Step 5: Commit**
 170+
 171+```bash
 172+jj commit -m "feat(hooks): add commit message parser for issue references"
 173+```
 174+
 175+---
 176+
 177+## Task 2: Create git-bug Issue Resolver
 178+
 179+**Files:**
 180+- Create: `hooks/issue_resolver.go`
 181+- Test: `hooks/issue_resolver_test.go`
 182+
 183+**Step 1: Write the failing test**
 184+
 185+```go
 186+package hooks
 187+
 188+import (
 189+	"testing"
 190+
 191+	"github.com/git-bug/git-bug/entity"
 192+)
 193+
 194+func TestFindBugByShortID(t *testing.T) {
 195+	// This test would require a mock repository
 196+	// For now, just test the short ID matching logic
 197+	tests := []struct {
 198+		shortID  string
 199+		fullID   entity.Id
 200+		expected bool
 201+	}{
 202+		{"872a52d", "872a52d8a57756003bb29a33a1527824a1058f7e1fbb764b4eb24f9fad408c75", true},
 203+		{"872a52d", "872a52d8a57756003bb29a33a1527824a1058f7e", true}, // shorter full ID
 204+		{"872a52d", "abc123d8a57756003bb29a33a1527824a1058f7e", false},
 205+	}
 206+
 207+	for _, tt := range tests {
 208+		result := matchesShortID(tt.shortID, tt.fullID)
 209+		if result != tt.expected {
 210+			t.Errorf("matchesShortID(%q, %q) = %v, want %v", tt.shortID, tt.fullID, result, tt.expected)
 211+		}
 212+	}
 213+}
 214+```
 215+
 216+**Step 2: Run test to verify it fails**
 217+
 218+Run: `go test ./hooks/...`
 219+
 220+Expected: FAIL with "undefined: matchesShortID"
 221+
 222+**Step 3: Write minimal implementation**
 223+
 224+```go
 225+package hooks
 226+
 227+import (
 228+	"strings"
 229+
 230+	"github.com/git-bug/git-bug/entities/bug"
 231+	"github.com/git-bug/git-bug/entity"
 232+	"github.com/git-bug/git-bug/repository"
 233+)
 234+
 235+// BugResolver handles looking up bugs by their short IDs
 236+type BugResolver struct {
 237+	repo repository.ClockedRepo
 238+}
 239+
 240+// NewBugResolver creates a new BugResolver for the given repository
 241+func NewBugResolver(repoPath string) (*BugResolver, error) {
 242+	repo, err := repository.OpenGoGitRepo(repoPath, nil, nil)
 243+	if err != nil {
 244+		return nil, err
 245+	}
 246+	return &BugResolver{repo: repo}, nil
 247+}
 248+
 249+// FindBugByShortID looks up a bug by its 7-character short ID
 250+func (r *BugResolver) FindBugByShortID(shortID string) (*bug.Bug, error) {
 251+	shortID = strings.ToLower(shortID)
 252+
 253+	for streamedBug := range bug.ReadAll(r.repo) {
 254+		if streamedBug.Err != nil {
 255+			continue
 256+		}
 257+		b := streamedBug.Entity
 258+		if matchesShortID(shortID, b.Id()) {
 259+			return b, nil
 260+		}
 261+	}
 262+
 263+	return nil, nil // not found
 264+}
 265+
 266+// matchesShortID checks if a short ID (7 chars) matches the beginning of a full bug ID
 267+func matchesShortID(shortID string, fullID entity.Id) bool {
 268+	return strings.HasPrefix(strings.ToLower(fullID.String()), strings.ToLower(shortID))
 269+}
 270+
 271+// Close closes the underlying repository resources
 272+func (r *BugResolver) Close() error {
 273+	if closer, ok := r.repo.(interface{ Close() error }); ok {
 274+		return closer.Close()
 275+	}
 276+	return nil
 277+}
 278+```
 279+
 280+**Step 4: Run test to verify it passes**
 281+
 282+Run: `go test ./hooks/...`
 283+
 284+Expected: PASS
 285+
 286+**Step 5: Commit**
 287+
 288+```bash
 289+jj commit -m "feat(hooks): add bug resolver for looking up issues by short ID"
 290+```
 291+
 292+---
 293+
 294+## Task 3: Create Issue Comment Generator
 295+
 296+**Files:**
 297+- Create: `hooks/comment_generator.go`
 298+- Test: `hooks/comment_generator_test.go`
 299+
 300+**Step 1: Write the failing test**
 301+
 302+```go
 303+package hooks
 304+
 305+import (
 306+	"testing"
 307+)
 308+
 309+func TestGenerateComment(t *testing.T) {
 310+	generator := &CommentGenerator{
 311+		RootRelative: "/",
 312+		HomeURL:      "https://git.example.com",
 313+	}
 314+
 315+	tests := []struct {
 316+		name      string
 317+		commitID  string
 318+		expected  string
 319+	}{
 320+		{
 321+			name:     "basic commit",
 322+			commitID: "1e12c021ffe53632ffd00c1c85752ad43e62f0ed",
 323+			expected: "Fixed in commit [1e12c02](/commits/1e12c021ffe53632ffd00c1c85752ad43e62f0ed.html)",
 324+		},
 325+	}
 326+
 327+	for _, tt := range tests {
 328+		t.Run(tt.name, func(t *testing.T) {
 329+			result := generator.GenerateComment(tt.commitID)
 330+			if result != tt.expected {
 331+				t.Errorf("expected %q, got %q", tt.expected, result)
 332+			}
 333+		})
 334+	}
 335+}
 336+```
 337+
 338+**Step 2: Run test to verify it fails**
 339+
 340+Run: `go test ./hooks/...`
 341+
 342+Expected: FAIL with "undefined: CommentGenerator"
 343+
 344+**Step 3: Write minimal implementation**
 345+
 346+```go
 347+package hooks
 348+
 349+import (
 350+	"fmt"
 351+	"strings"
 352+)
 353+
 354+// CommentGenerator generates markdown comments for git-bug issues
 355+type CommentGenerator struct {
 356+	RootRelative string // e.g., "/" or "/myrepo/"
 357+	HomeURL      string // e.g., "https://git.example.com"
 358+}
 359+
 360+// GenerateComment creates a comment linking to the commit
 361+func (g *CommentGenerator) GenerateComment(commitID string) string {
 362+	shortID := getShortID(commitID)
 363+	commitURL := g.generateCommitURL(commitID)
 364+	return fmt.Sprintf("Fixed in commit [%s](%s)", shortID, commitURL)
 365+}
 366+
 367+// generateCommitURL creates the URL to the commit page in pgit
 368+func (g *CommentGenerator) generateCommitURL(commitID string) string {
 369+	// Ensure root relative ends with slash for proper joining
 370+	root := g.RootRelative
 371+	if !strings.HasSuffix(root, "/") {
 372+		root = root + "/"
 373+	}
 374+	return fmt.Sprintf("%scommits/%s.html", root, commitID)
 375+}
 376+
 377+// getShortID returns the first 7 characters of a git hash
 378+func getShortID(id string) string {
 379+	if len(id) <= 7 {
 380+		return id
 381+	}
 382+	return id[:7]
 383+}
 384+```
 385+
 386+**Step 4: Run test to verify it passes**
 387+
 388+Run: `go test ./hooks/...`
 389+
 390+Expected: PASS
 391+
 392+**Step 5: Commit**
 393+
 394+```bash
 395+jj commit -m "feat(hooks): add comment generator for issue references"
 396+```
 397+
 398+---
 399+
 400+## Task 4: Create git-bug Issue Closer
 401+
 402+**Files:**
 403+- Create: `hooks/issue_closer.go`
 404+- Test: `hooks/issue_closer_test.go`
 405+
 406+**Step 1: Write the failing test**
 407+
 408+```go
 409+package hooks
 410+
 411+import (
 412+	"testing"
 413+)
 414+
 415+func TestIssueCloserConfig(t *testing.T) {
 416+	config := IssueCloserConfig{
 417+		RepoPath:     "/path/to/repo",
 418+		RootRelative: "/",
 419+	}
 420+
 421+	if config.RepoPath != "/path/to/repo" {
 422+		t.Error("RepoPath not set correctly")
 423+	}
 424+}
 425+```
 426+
 427+**Step 2: Run test to verify it fails**
 428+
 429+Run: `go test ./hooks/...`
 430+
 431+Expected: FAIL with "undefined: IssueCloserConfig"
 432+
 433+**Step 3: Write minimal implementation**
 434+
 435+```go
 436+package hooks
 437+
 438+import (
 439+	"fmt"
 440+	"time"
 441+
 442+	"github.com/git-bug/git-bug/entities/bug"
 443+	"github.com/git-bug/git-bug/entities/common"
 444+	"github.com/git-bug/git-bug/entities/identity"
 445+	"github.com/git-bug/git-bug/repository"
 446+)
 447+
 448+// IssueCloserConfig holds configuration for the issue closer
 449+type IssueCloserConfig struct {
 450+	RepoPath     string // Path to the git repository
 451+	RootRelative string // Root-relative URL path for pgit
 452+}
 453+
 454+// IssueCloser handles closing git-bug issues and adding comments
 455+type IssueCloser struct {
 456+	config IssueCloserConfig
 457+	repo   repository.ClockedRepo
 458+}
 459+
 460+// NewIssueCloser creates a new IssueCloser
 461+func NewIssueCloser(config IssueCloserConfig) (*IssueCloser, error) {
 462+	repo, err := repository.OpenGoGitRepo(config.RepoPath, nil, nil)
 463+	if err != nil {
 464+		return nil, fmt.Errorf("failed to open repository: %w", err)
 465+	}
 466+
 467+	return &IssueCloser{
 468+		config: config,
 469+		repo:   repo,
 470+	}, nil
 471+}
 472+
 473+// Close closes the repository resources
 474+func (c *IssueCloser) Close() error {
 475+	if closer, ok := c.repo.(interface{ Close() error }); ok {
 476+		return closer.Close()
 477+	}
 478+	return nil
 479+}
 480+
 481+// ProcessIssue closes a bug and adds a comment referencing the commit
 482+func (c *IssueCloser) ProcessIssue(issueRef IssueReference, commitID string) error {
 483+	// Find the bug by short ID
 484+	resolver := &BugResolver{repo: c.repo}
 485+	b, err := resolver.FindBugByShortID(issueRef.IssueID)
 486+	if err != nil {
 487+		return fmt.Errorf("failed to find bug %s: %w", issueRef.IssueID, err)
 488+	}
 489+	if b == nil {
 490+		return fmt.Errorf("bug with ID %s not found", issueRef.IssueID)
 491+	}
 492+
 493+	// Get the current user identity from git config
 494+	author, err := identity.NewFromGitUser(c.repo)
 495+	if err != nil {
 496+		return fmt.Errorf("failed to get git user identity: %w", err)
 497+	}
 498+
 499+	now := time.Now().Unix()
 500+
 501+	// Add comment first
 502+	generator := &CommentGenerator{
 503+		RootRelative: c.config.RootRelative,
 504+	}
 505+	comment := generator.GenerateComment(commitID)
 506+
 507+	_, _, err = bug.AddComment(b, author, now, comment, nil, nil)
 508+	if err != nil {
 509+		return fmt.Errorf("failed to add comment to bug %s: %w", issueRef.IssueID, err)
 510+	}
 511+
 512+	// Then close the bug
 513+	_, err = bug.Close(b, author, now, nil)
 514+	if err != nil {
 515+		return fmt.Errorf("failed to close bug %s: %w", issueRef.IssueID, err)
 516+	}
 517+
 518+	// Commit the changes to git-bug storage
 519+	err = b.Commit(c.repo)
 520+	if err != nil {
 521+		return fmt.Errorf("failed to commit bug changes: %w", err)
 522+	}
 523+
 524+	return nil
 525+}
 526+```
 527+
 528+**Step 4: Run test to verify it passes**
 529+
 530+Run: `go test ./hooks/...`
 531+
 532+Expected: PASS
 533+
 534+**Step 5: Commit**
 535+
 536+```bash
 537+jj commit -m "feat(hooks): add issue closer with comment and close operations"
 538+```
 539+
 540+---
 541+
 542+## Task 5: Create the commit-msg Hook CLI
 543+
 544+**Files:**
 545+- Create: `cmd/pgit-hooks/main.go`
 546+
 547+**Step 1: Create the CLI entry point**
 548+
 549+```go
 550+package main
 551+
 552+import (
 553+	"flag"
 554+	"fmt"
 555+	"os"
 556+	"path/filepath"
 557+	"strings"
 558+
 559+	"github.com/picosh/pgit/hooks"
 560+)
 561+
 562+func main() {
 563+	var (
 564+		repoPath     = flag.String("repo", ".", "path to git repository")
 565+		rootRelative = flag.String("root-relative", "/", "root-relative URL path for pgit")
 566+		commitID     = flag.String("commit", "", "commit hash (optional, will use HEAD if not provided)")
 567+	)
 568+	flag.Parse()
 569+
 570+	if len(flag.Args()) < 1 {
 571+		fmt.Fprintln(os.Stderr, "Usage: pgit-hooks commit-msg <commit-message-file>")
 572+		fmt.Fprintln(os.Stderr, "       pgit-hooks --commit=<hash> commit-msg <commit-message-file>")
 573+		os.Exit(1)
 574+	}
 575+
 576+	// Get the commit message file path
 577+	commitMsgFile := flag.Args()[0]
 578+
 579+	// Read the commit message
 580+	message, err := os.ReadFile(commitMsgFile)
 581+	if err != nil {
 582+		fmt.Fprintf(os.Stderr, "Error reading commit message: %v\n", err)
 583+		os.Exit(1)
 584+	}
 585+
 586+	// Get the commit ID - use provided or get HEAD
 587+	commitHash := *commitID
 588+	if commitHash == "" {
 589+		// Try to get HEAD commit hash
 590+		commitHash = getHeadCommit(*repoPath)
 591+		if commitHash == "" {
 592+			fmt.Fprintln(os.Stderr, "Warning: Could not determine commit hash, skipping issue updates")
 593+			os.Exit(0)
 594+		}
 595+	}
 596+
 597+	// Parse issue references
 598+	refs := hooks.ParseIssueReferences(string(message))
 599+	if len(refs) == 0 {
 600+		// No issue references found, exit silently
 601+		os.Exit(0)
 602+	}
 603+
 604+	// Process each issue reference
 605+	config := hooks.IssueCloserConfig{
 606+		RepoPath:     *repoPath,
 607+		RootRelative: *rootRelative,
 608+	}
 609+
 610+	closer, err := hooks.NewIssueCloser(config)
 611+	if err != nil {
 612+		fmt.Fprintf(os.Stderr, "Error initializing issue closer: %v\n", err)
 613+		os.Exit(1)
 614+	}
 615+	defer closer.Close()
 616+
 617+	var errors []string
 618+	for _, ref := range refs {
 619+		fmt.Fprintf(os.Stderr, "Processing issue #%s...\n", ref.IssueID)
 620+		err := closer.ProcessIssue(ref, commitHash)
 621+		if err != nil {
 622+			fmt.Fprintf(os.Stderr, "Error processing issue #%s: %v\n", ref.IssueID, err)
 623+			errors = append(errors, fmt.Sprintf("%s: %v", ref.IssueID, err))
 624+		} else {
 625+			fmt.Fprintf(os.Stderr, "Successfully closed issue #%s\n", ref.IssueID)
 626+		}
 627+	}
 628+
 629+	if len(errors) > 0 {
 630+		fmt.Fprintf(os.Stderr, "\nErrors occurred:\n%s\n", strings.Join(errors, "\n"))
 631+		// Don't fail the commit, just warn
 632+	}
 633+
 634+	os.Exit(0)
 635+}
 636+
 637+// getHeadCommit attempts to get the HEAD commit hash from the repo
 638+func getHeadCommit(repoPath string) string {
 639+	// Read HEAD file
 640+	headFile := filepath.Join(repoPath, ".git", "HEAD")
 641+	data, err := os.ReadFile(headFile)
 642+	if err != nil {
 643+		return ""
 644+	}
 645+
 646+	ref := strings.TrimSpace(string(data))
 647+
 648+	// If HEAD is a reference, resolve it
 649+	if strings.HasPrefix(ref, "ref: ") {
 650+		refPath := strings.TrimPrefix(ref, "ref: ")
 651+		refFile := filepath.Join(repoPath, ".git", refPath)
 652+		data, err = os.ReadFile(refFile)
 653+		if err != nil {
 654+			return ""
 655+		}
 656+		return strings.TrimSpace(string(data))
 657+	}
 658+
 659+	// HEAD is a detached commit
 660+	return ref
 661+}
 662+```
 663+
 664+**Step 2: Commit**
 665+
 666+```bash
 667+jj commit -m "feat(hooks): add commit-msg hook CLI entry point"
 668+```
 669+
 670+---
 671+
 672+## Task 6: Add Install Command
 673+
 674+**Files:**
 675+- Create: `cmd/pgit-hooks/install.go`
 676+
 677+**Step 1: Create the install command**
 678+
 679+```go
 680+package main
 681+
 682+import (
 683+	"fmt"
 684+	"os"
 685+	"path/filepath"
 686+	"strings"
 687+)
 688+
 689+// installHook installs the commit-msg hook into the repository
 690+func installHook(repoPath string) error {
 691+	hooksDir := filepath.Join(repoPath, ".git", "hooks")
 692+	commitMsgHook := filepath.Join(hooksDir, "commit-msg")
 693+
 694+	// Check if a commit-msg hook already exists
 695+	if _, err := os.Stat(commitMsgHook); err == nil {
 696+		// Backup existing hook
 697+		backupPath := commitMsgHook + ".backup"
 698+		if err := os.Rename(commitMsgHook, backupPath); err != nil {
 699+			return fmt.Errorf("failed to backup existing hook: %w", err)
 700+		}
 701+		fmt.Printf("Backed up existing commit-msg hook to %s\n", backupPath)
 702+	}
 703+
 704+	// Find the pgit-hooks binary
 705+	pgitHooksPath, err := os.Executable()
 706+	if err != nil {
 707+		return fmt.Errorf("failed to get executable path: %w", err)
 708+	}
 709+
 710+	// Create the hook script
 711+	hookContent := fmt.Sprintf(`#!/bin/sh
 712+# pgit commit-msg hook
 713+# Automatically closes git-bug issues referenced in commit messages
 714+
 715+COMMIT_MSG_FILE="$1"
 716+COMMIT_SOURCE="$2"
 717+
 718+# Skip merge commits, squash commits, etc.
 719+if [ -n "$COMMIT_SOURCE" ]; then
 720+	case "$COMMIT_SOURCE" in
 721+		merge|squash|commit)
 722+			exit 0
 723+			;;
 724+	esac
 725+fi
 726+
 727+# Run pgit-hooks
 728+exec "%s" --repo="%s" commit-msg "$COMMIT_MSG_FILE"
 729+`, pgitHooksPath, repoPath)
 730+
 731+	if err := os.WriteFile(commitMsgHook, []byte(hookContent), 0755); err != nil {
 732+		return fmt.Errorf("failed to write hook file: %w", err)
 733+	}
 734+
 735+	fmt.Printf("Installed commit-msg hook to %s\n", commitMsgHook)
 736+	return nil
 737+}
 738+
 739+// uninstallHook removes the pgit commit-msg hook
 740+func uninstallHook(repoPath string) error {
 741+	commitMsgHook := filepath.Join(repoPath, ".git", "hooks", "commit-msg")
 742+
 743+	// Check if it's our hook
 744+	data, err := os.ReadFile(commitMsgHook)
 745+	if err != nil {
 746+		if os.IsNotExist(err) {
 747+			fmt.Println("No commit-msg hook found")
 748+			return nil
 749+		}
 750+		return fmt.Errorf("failed to read hook: %w", err)
 751+	}
 752+
 753+	if !strings.Contains(string(data), "pgit commit-msg hook") {
 754+		return fmt.Errorf("commit-msg hook is not a pgit hook (not removing)")
 755+	}
 756+
 757+	if err := os.Remove(commitMsgHook); err != nil {
 758+		return fmt.Errorf("failed to remove hook: %w", err)
 759+	}
 760+
 761+	// Restore backup if exists
 762+	backupPath := commitMsgHook + ".backup"
 763+	if _, err := os.Stat(backupPath); err == nil {
 764+		if err := os.Rename(backupPath, commitMsgHook); err != nil {
 765+			return fmt.Errorf("failed to restore backup: %w", err)
 766+		}
 767+		fmt.Printf("Restored backup hook from %s\n", backupPath)
 768+	}
 769+
 770+	fmt.Println("Uninstalled pgit commit-msg hook")
 771+	return nil
 772+}
 773+```
 774+
 775+**Step 2: Update main.go to support install/uninstall commands**
 776+
 777+Modify `cmd/pgit-hooks/main.go` to add subcommand handling:
 778+
 779+```go
 780+package main
 781+
 782+import (
 783+	"flag"
 784+	"fmt"
 785+	"os"
 786+	"path/filepath"
 787+	"strings"
 788+
 789+	"github.com/picosh/pgit/hooks"
 790+)
 791+
 792+func main() {
 793+	if len(os.Args) < 2 {
 794+		printUsage()
 795+		os.Exit(1)
 796+	}
 797+
 798+	subcommand := os.Args[1]
 799+
 800+	switch subcommand {
 801+	case "install":
 802+		handleInstall()
 803+	case "uninstall":
 804+		handleUninstall()
 805+	case "commit-msg":
 806+		handleCommitMsg()
 807+	default:
 808+		fmt.Fprintf(os.Stderr, "Unknown command: %s\n", subcommand)
 809+		printUsage()
 810+		os.Exit(1)
 811+	}
 812+}
 813+
 814+func printUsage() {
 815+	fmt.Fprintln(os.Stderr, "Usage: pgit-hooks <command> [options]")
 816+	fmt.Fprintln(os.Stderr, "")
 817+	fmt.Fprintln(os.Stderr, "Commands:")
 818+	fmt.Fprintln(os.Stderr, "  install       Install the commit-msg hook")
 819+	fmt.Fprintln(os.Stderr, "  uninstall     Uninstall the commit-msg hook")
 820+	fmt.Fprintln(os.Stderr, "  commit-msg    Run the commit-msg hook (used by git)")
 821+	fmt.Fprintln(os.Stderr, "")
 822+	fmt.Fprintln(os.Stderr, "For commit-msg command:")
 823+	fmt.Fprintln(os.Stderr, "  pgit-hooks commit-msg <commit-message-file>")
 824+}
 825+
 826+func handleInstall() {
 827+	fs := flag.NewFlagSet("install", flag.ExitOnError)
 828+	repoPath := fs.String("repo", ".", "path to git repository")
 829+	fs.Parse(os.Args[2:])
 830+
 831+	absRepo, err := filepath.Abs(*repoPath)
 832+	if err != nil {
 833+		fmt.Fprintf(os.Stderr, "Error resolving repo path: %v\n", err)
 834+		os.Exit(1)
 835+	}
 836+
 837+	if err := installHook(absRepo); err != nil {
 838+		fmt.Fprintf(os.Stderr, "Error installing hook: %v\n", err)
 839+		os.Exit(1)
 840+	}
 841+}
 842+
 843+func handleUninstall() {
 844+	fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
 845+	repoPath := fs.String("repo", ".", "path to git repository")
 846+	fs.Parse(os.Args[2:])
 847+
 848+	absRepo, err := filepath.Abs(*repoPath)
 849+	if err != nil {
 850+		fmt.Fprintf(os.Stderr, "Error resolving repo path: %v\n", err)
 851+		os.Exit(1)
 852+	}
 853+
 854+	if err := uninstallHook(absRepo); err != nil {
 855+		fmt.Fprintf(os.Stderr, "Error uninstalling hook: %v\n", err)
 856+		os.Exit(1)
 857+	}
 858+}
 859+
 860+func handleCommitMsg() {
 861+	fs := flag.NewFlagSet("commit-msg", flag.ExitOnError)
 862+	repoPath := fs.String("repo", ".", "path to git repository")
 863+	rootRelative := fs.String("root-relative", "/", "root-relative URL path for pgit")
 864+	commitID := fs.String("commit", "", "commit hash (optional, will use HEAD if not provided)")
 865+	fs.Parse(os.Args[2:])
 866+
 867+	if fs.NArg() < 1 {
 868+		fmt.Fprintln(os.Stderr, "Usage: pgit-hooks commit-msg <commit-message-file>")
 869+		os.Exit(1)
 870+	}
 871+
 872+	commitMsgFile := fs.Arg(0)
 873+
 874+	// Read the commit message
 875+	message, err := os.ReadFile(commitMsgFile)
 876+	if err != nil {
 877+		fmt.Fprintf(os.Stderr, "Error reading commit message: %v\n", err)
 878+		os.Exit(1)
 879+	}
 880+
 881+	// Get the commit ID
 882+	commitHash := *commitID
 883+	if commitHash == "" {
 884+		commitHash = getHeadCommit(*repoPath)
 885+		if commitHash == "" {
 886+			fmt.Fprintln(os.Stderr, "Warning: Could not determine commit hash, skipping issue updates")
 887+			os.Exit(0)
 888+		}
 889+	}
 890+
 891+	// Parse issue references
 892+	refs := hooks.ParseIssueReferences(string(message))
 893+	if len(refs) == 0 {
 894+		os.Exit(0)
 895+	}
 896+
 897+	// Process each issue reference
 898+	config := hooks.IssueCloserConfig{
 899+		RepoPath:     *repoPath,
 900+		RootRelative: *rootRelative,
 901+	}
 902+
 903+	closer, err := hooks.NewIssueCloser(config)
 904+	if err != nil {
 905+		fmt.Fprintf(os.Stderr, "Error initializing issue closer: %v\n", err)
 906+		os.Exit(1)
 907+	}
 908+	defer closer.Close()
 909+
 910+	for _, ref := range refs {
 911+		fmt.Fprintf(os.Stderr, "Processing issue #%s...\n", ref.IssueID)
 912+		err := closer.ProcessIssue(ref, commitHash)
 913+		if err != nil {
 914+			fmt.Fprintf(os.Stderr, "Error processing issue #%s: %v\n", ref.IssueID, err)
 915+		} else {
 916+			fmt.Fprintf(os.Stderr, "Successfully closed issue #%s\n", ref.IssueID)
 917+		}
 918+	}
 919+
 920+	os.Exit(0)
 921+}
 922+
 923+func getHeadCommit(repoPath string) string {
 924+	headFile := filepath.Join(repoPath, ".git", "HEAD")
 925+	data, err := os.ReadFile(headFile)
 926+	if err != nil {
 927+		return ""
 928+	}
 929+
 930+	ref := strings.TrimSpace(string(data))
 931+
 932+	if strings.HasPrefix(ref, "ref: ") {
 933+		refPath := strings.TrimPrefix(ref, "ref: ")
 934+		refFile := filepath.Join(repoPath, ".git", refPath)
 935+		data, err = os.ReadFile(refFile)
 936+		if err != nil {
 937+			return ""
 938+		}
 939+		return strings.TrimSpace(string(data))
 940+	}
 941+
 942+	return ref
 943+}
 944+```
 945+
 946+**Step 3: Commit**
 947+
 948+```bash
 949+jj commit -m "feat(hooks): add install and uninstall commands for commit-msg hook"
 950+```
 951+
 952+---
 953+
 954+## Task 7: Update go.mod and Build
 955+
 956+**Files:**
 957+- Modify: `go.mod`
 958+- Run: Build commands
 959+
 960+**Step 1: Ensure git-bug is a direct dependency**
 961+
 962+Run: `go get github.com/git-bug/[email protected]`
 963+
 964+**Step 2: Update go.mod**
 965+
 966+Add to go.mod's require block if not already present:
 967+
 968+```
 969+github.com/git-bug/git-bug v0.10.1
 970+```
 971+
 972+**Step 3: Run go mod tidy**
 973+
 974+Run: `go mod tidy`
 975+
 976+**Step 4: Build both binaries**
 977+
 978+Run:
 979+```bash
 980+go build -o pgit .
 981+go build -o pgit-hooks ./cmd/pgit-hooks
 982+```
 983+
 984+**Step 5: Verify builds succeed**
 985+
 986+Expected: Both binaries built successfully
 987+
 988+**Step 6: Commit**
 989+
 990+```bash
 991+jj commit -m "build: update dependencies and build pgit-hooks binary"
 992+```
 993+
 994+---
 995+
 996+## Task 8: Create Hook Installation Documentation
 997+
 998+**Files:**
 999+- Modify: `README.md`
1000+
1001+**Step 1: Add hook installation section to README**
1002+
1003+Add after the git-bug integration section:
1004+
1005+```markdown
1006+## commit-msg hook
1007+
1008+pgit includes a commit-msg hook that automatically closes git-bug issues when
1009+you reference them in commit messages.
1010+
1011+### Installation
1012+
1013+Install the hook in your repository:
1014+
1015+```bash
1016+./pgit-hooks install --repo=/path/to/your/repo
1017+```
1018+
1019+### Usage
1020+
1021+Once installed, the hook will automatically:
1022+
1023+1. Parse your commit messages for issue references
1024+2. Close the referenced git-bug issues
1025+3. Add a comment linking back to the commit
1026+
1027+Supported keywords (case-insensitive):
1028+- `fix`, `fixes`, `fixed`
1029+- `close`, `closes`, `closed`
1030+- `resolve`, `resolves`, `resolved`
1031+
1032+Example commit messages:
1033+
1034+```
1035+fixes #872a52d - resolved the memory leak
1036+```
1037+
1038+```
1039+This commit closes #abc1234 and resolves #def5678
1040+```
1041+
1042+### Configuration
1043+
1044+The hook uses pgit's URL scheme for commit links. Configure `--root-relative`
1045+when installing to match your pgit setup:
1046+
1047+```bash
1048+./pgit-hooks install --repo=/path/to/repo
1049+# Then edit .git/hooks/commit-msg to add --root-relative flag
1050+```
1051+
1052+### Uninstallation
1053+
1054+Remove the hook:
1055+
1056+```bash
1057+./pgit-hooks uninstall --repo=/path/to/your/repo
1058+```
1059+```
1060+
1061+**Step 2: Commit**
1062+
1063+```bash
1064+jj commit -m "docs: add commit-msg hook documentation"
1065+```
1066+
1067+---
1068+
1069+## Task 9: Manual Testing
1070+
1071+**Files:**
1072+- Run: Manual tests
1073+
1074+**Step 1: Build both binaries**
1075+
1076+```bash
1077+go build -o pgit .
1078+go build -o pgit-hooks ./cmd/pgit-hooks
1079+```
1080+
1081+**Step 2: Test in a repo with git-bug**
1082+
1083+```bash
1084+# Navigate to a repo with git-bug issues
1085+cd testdata.repo
1086+
1087+# Install the hook
1088+../pgit-hooks install
1089+
1090+# Create a test commit referencing an issue
1091+echo "test" >> testfile
1092+git add testfile
1093+git commit -m "fixes #872a52d - testing the hook"
1094+
1095+# Verify the issue was closed
1096+git bug show 872a52d
1097+```
1098+
1099+**Step 3: Test with --root-relative flag**
1100+
1101+```bash
1102+# Edit the hook to use a custom root-relative path
1103+# Then commit again and verify links work
1104+```
1105+
1106+**Step 4: Commit**
1107+
1108+```bash
1109+jj commit -m "test: verify commit-msg hook works correctly"
1110+```
1111+
1112+---
1113+
1114+## Summary
1115+
1116+After completing all tasks, the pgit project will have:
1117+
1118+1. A `pgit-hooks` binary that can be installed as a git commit-msg hook
1119+2. Automatic parsing of commit messages for issue references like `fixes #872a52d`
1120+3. Automatic closing of referenced git-bug issues
1121+4. Automatic commenting on issues with links to the fixing commit
1122+5. Support for multiple keywords: fix, close, resolve (and their variants)
1123+6. Install/uninstall commands for easy setup
1124+7. Documentation in README.md
1125+
1126+**Files Created:**
1127+- `hooks/commit_msg_parser.go` - Parses commit messages for issue refs
1128+- `hooks/commit_msg_parser_test.go` - Tests for parser
1129+- `hooks/issue_resolver.go` - Resolves short IDs to full bug objects
1130+- `hooks/issue_resolver_test.go` - Tests for resolver
1131+- `hooks/comment_generator.go` - Generates markdown comments
1132+- `hooks/comment_generator_test.go` - Tests for comment generator
1133+- `hooks/issue_closer.go` - Closes bugs and adds comments
1134+- `hooks/issue_closer_test.go` - Tests for issue closer
1135+- `cmd/pgit-hooks/main.go` - CLI entry point
1136+- `cmd/pgit-hooks/install.go` - Install/uninstall commands
1137+
1138+**Files Modified:**
1139+- `go.mod` - Added git-bug dependency
1140+- `README.md` - Added documentation
1141+
1142+**Usage:**
1143+```bash
1144+# Install the hook
1145+./pgit-hooks install --repo=/path/to/repo
1146+
1147+# Now commits with issue references will auto-close issues
1148+# Example: git commit -m "fixes #872a52d - fixed the bug"
1149+
1150+# Uninstall
1151+./pgit-hooks uninstall --repo=/path/to/repo
1152+```
A docs/plans/2026-04-11-commit-msg-hook-simple.md
+592, -0
  1@@ -0,0 +1,592 @@
  2+# Git Commit Hook for git-bug (Simple Bash Version)
  3+
  4+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
  5+
  6+**Goal:** Create a bash commit-msg hook that parses commit messages for git-bug issue references and uses the `git-bug` CLI to comment on and close issues.
  7+
  8+**Architecture:** A simple bash script installed as `.git/hooks/commit-msg` that parses the commit message for patterns like "fixes #872a52d", extracts the issue ID, then calls `git-bug bug comment new` and `git-bug bug close` to update the issue. Links are generated later during pgit static site generation.
  9+
 10+**Tech Stack:** Bash, git-bug CLI, regex with grep/sed
 11+
 12+---
 13+
 14+## Overview
 15+
 16+This feature has two parts:
 17+
 18+1. **commit-msg hook** (bash): Parses commit messages and updates git-bug issues via CLI
 19+2. **pgit link transformation** (Go): During static site generation, transforms "Fixed in commit [hash]" text into clickable links
 20+
 21+**Supported Keywords:**
 22+- `fix`, `fixes`, `fixed` - closes the issue
 23+- `close`, `closes`, `closed` - closes the issue  
 24+- `resolve`, `resolves`, `resolved` - closes the issue
 25+
 26+**Comment Format:**
 27+```
 28+Fixed in commit 1e12c021ffe53632ffd00c1c85752ad43e62f0ed
 29+```
 30+
 31+**Link Transformation:** During pgit build, this becomes:
 32+```html
 33+Fixed in commit <a href="{root-relative}commits/1e12c021ffe53632ffd00c1c85752ad43e62f0ed.html">1e12c02</a>
 34+```
 35+
 36+---
 37+
 38+## Task 1: Create the Bash commit-msg Hook
 39+
 40+**Files:**
 41+- Create: `hooks/commit-msg.bash`
 42+
 43+**Step 1: Create the hook script**
 44+
 45+```bash
 46+#!/bin/bash
 47+# pgit commit-msg hook
 48+# Automatically closes git-bug issues referenced in commit messages
 49+#
 50+# Usage: Install as .git/hooks/commit-msg
 51+
 52+set -e
 53+
 54+COMMIT_MSG_FILE="$1"
 55+COMMIT_SOURCE="$2"
 56+
 57+# Skip merge commits, squash commits, etc.
 58+if [ -n "$COMMIT_SOURCE" ]; then
 59+	case "$COMMIT_SOURCE" in
 60+		merge|squash|commit)
 61+			exit 0
 62+			;;
 63+	esac
 64+fi
 65+
 66+# Check if git-bug is available
 67+if ! command -v git-bug &> /dev/null; then
 68+	echo "Warning: git-bug not found in PATH, skipping issue updates" >&2
 69+	exit 0
 70+fi
 71+
 72+# Read the commit message
 73+COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
 74+
 75+# Get the commit hash (HEAD after commit)
 76+COMMIT_HASH=$(git rev-parse HEAD)
 77+if [ -z "$COMMIT_HASH" ]; then
 78+	echo "Warning: Could not determine commit hash, skipping issue updates" >&2
 79+	exit 0
 80+fi
 81+
 82+# Extract issue references using regex
 83+# Matches: fixes #872a52d, close #abc1234, resolved #def5678, etc.
 84+# Case insensitive, captures the 7-char hex ID
 85+ISSUE_REFS=$(echo "$COMMIT_MSG" | grep -oEi '(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)[[:space:]]*#[a-f0-9]{7}\b' || true)
 86+
 87+if [ -z "$ISSUE_REFS" ]; then
 88+	# No issue references found
 89+	exit 0
 90+fi
 91+
 92+# Process each reference
 93+echo "$ISSUE_REFS" | while read -r ref; do
 94+	# Extract the issue ID (the hex part after #)
 95+	issue_id=$(echo "$ref" | grep -oE '#[a-f0-9]{7}\b' | sed 's/^#//i' | tr '[:upper:]' '[:lower:]')
 96+	
 97+	if [ -z "$issue_id" ]; then
 98+		continue
 99+	fi
100+	
101+	echo "Processing git-bug issue #$issue_id..." >&2
102+	
103+	# Add comment with commit reference
104+	comment="Fixed in commit $COMMIT_HASH"
105+	
106+	# Try to add comment - don't fail the commit if this fails
107+	if git-bug bug comment new "$issue_id" -m "$comment" 2>/dev/null; then
108+		echo "  Added comment to issue #$issue_id" >&2
109+	else
110+		echo "  Warning: Failed to add comment to issue #$issue_id" >&2
111+	fi
112+	
113+	# Try to close the issue - don't fail the commit if this fails
114+	if git-bug bug close "$issue_id" 2>/dev/null; then
115+		echo "  Closed issue #$issue_id" >&2
116+	else
117+		echo "  Warning: Failed to close issue #$issue_id" >&2
118+	fi
119+done
120+
121+exit 0
122+```
123+
124+**Step 2: Make the script executable**
125+
126+Run: `chmod +x hooks/commit-msg.bash`
127+
128+**Step 3: Commit**
129+
130+```bash
131+jj commit -m "feat(hooks): add bash commit-msg hook for git-bug integration"
132+```
133+
134+---
135+
136+## Task 2: Add Install/Uninstall Commands to pgit
137+
138+**Files:**
139+- Modify: `main.go` (add new commands)
140+
141+**Step 1: Add hook management commands**
142+
143+Add to `main.go` after the flag parsing section, before the config setup:
144+
145+```go
146+// Check for hook management commands
147+if len(os.Args) > 1 {
148+	switch os.Args[1] {
149+	case "install-hook":
150+		installHook(*rpath)
151+		return
152+	case "uninstall-hook":
153+		uninstallHook(*rpath)
154+		return
155+	}
156+}
157+```
158+
159+**Step 2: Add the hook management functions**
160+
161+Add these functions to `main.go`:
162+
163+```go
164+// installHook installs the commit-msg hook into the repository
165+func installHook(repoPath string) {
166+	// Find the embedded hook script
167+	hookContent, err := embedFS.ReadFile("hooks/commit-msg.bash")
168+	if err != nil {
169+		fmt.Fprintf(os.Stderr, "Error: Could not read embedded hook: %v\n", err)
170+		os.Exit(1)
171+	}
172+
173+	hooksDir := filepath.Join(repoPath, ".git", "hooks")
174+	commitMsgHook := filepath.Join(hooksDir, "commit-msg")
175+
176+	// Ensure hooks directory exists
177+	if err := os.MkdirAll(hooksDir, 0755); err != nil {
178+		fmt.Fprintf(os.Stderr, "Error: Could not create hooks directory: %v\n", err)
179+		os.Exit(1)
180+	}
181+
182+	// Check if a commit-msg hook already exists
183+	if _, err := os.Stat(commitMsgHook); err == nil {
184+		// Backup existing hook
185+		backupPath := commitMsgHook + ".backup"
186+		if err := os.Rename(commitMsgHook, backupPath); err != nil {
187+			fmt.Fprintf(os.Stderr, "Error: Failed to backup existing hook: %v\n", err)
188+			os.Exit(1)
189+		}
190+		fmt.Printf("Backed up existing commit-msg hook to %s\n", backupPath)
191+	}
192+
193+	// Write the hook
194+	if err := os.WriteFile(commitMsgHook, hookContent, 0755); err != nil {
195+		fmt.Fprintf(os.Stderr, "Error: Failed to write hook: %v\n", err)
196+		os.Exit(1)
197+	}
198+
199+	fmt.Printf("Installed commit-msg hook to %s\n", commitMsgHook)
200+	fmt.Println("Commits with issue references (e.g., 'fixes #872a52d') will now automatically close git-bug issues")
201+}
202+
203+// uninstallHook removes the pgit commit-msg hook
204+func uninstallHook(repoPath string) {
205+	commitMsgHook := filepath.Join(repoPath, ".git", "hooks", "commit-msg")
206+
207+	// Check if hook exists
208+	data, err := os.ReadFile(commitMsgHook)
209+	if err != nil {
210+		if os.IsNotExist(err) {
211+			fmt.Println("No commit-msg hook found")
212+			return
213+		}
214+		fmt.Fprintf(os.Stderr, "Error: Failed to read hook: %v\n", err)
215+		os.Exit(1)
216+	}
217+
218+	// Check if it's our hook
219+	if !strings.Contains(string(data), "pgit commit-msg hook") {
220+		fmt.Fprintf(os.Stderr, "Error: commit-msg hook is not a pgit hook (not removing)\n")
221+		os.Exit(1)
222+	}
223+
224+	// Remove the hook
225+	if err := os.Remove(commitMsgHook); err != nil {
226+		fmt.Fprintf(os.Stderr, "Error: Failed to remove hook: %v\n", err)
227+		os.Exit(1)
228+	}
229+
230+	// Restore backup if exists
231+	backupPath := commitMsgHook + ".backup"
232+	if _, err := os.Stat(backupPath); err == nil {
233+		if err := os.Rename(backupPath, commitMsgHook); err != nil {
234+			fmt.Fprintf(os.Stderr, "Error: Failed to restore backup: %v\n", err)
235+			os.Exit(1)
236+		}
237+		fmt.Printf("Restored backup hook from %s\n", backupPath)
238+	}
239+
240+	fmt.Println("Uninstalled pgit commit-msg hook")
241+}
242+```
243+
244+**Step 3: Update imports if needed**
245+
246+Ensure these imports exist in `main.go`:
247+```go
248+import (
249+	// ... existing imports ...
250+	"os"
251+	"path/filepath"
252+	"strings"
253+)
254+```
255+
256+**Step 4: Update embed directive**
257+
258+Add the hooks directory to the embed directive:
259+
260+```go
261+//go:embed html/*.tmpl
262+//go:embed static/*
263+//go:embed hooks/*.bash
264+var embedFS embed.FS
265+```
266+
267+**Step 5: Commit**
268+
269+```bash
270+jj commit -m "feat(hooks): add install-hook and uninstall-hook commands"
271+```
272+
273+---
274+
275+## Task 3: Transform Commit References to Links in Issue Pages
276+
277+**Files:**
278+- Modify: `issues.go` (add link transformation function)
279+
280+**Step 1: Add link transformation function**
281+
282+Add to `issues.go`:
283+
284+```go
285+import (
286+	// ... existing imports ...
287+	"regexp"
288+)
289+
290+// commitRefRegex matches "Fixed in commit [40-char-hash]" or similar patterns
291+// Captures the full 40-character commit hash
292+var commitRefRegex = regexp.MustCompile(`Fixed in commit ([a-f0-9]{40})\b`)
293+
294+// transformCommitRefs converts plain text commit references in issue comments
295+// into clickable links using pgit's URL scheme
296+func (c *Config) transformCommitRefs(content template.HTML) template.HTML {
297+	contentStr := string(content)
298+	
299+	result := commitRefRegex.ReplaceAllStringFunc(contentStr, func(match string) string {
300+		// Extract the hash from the match
301+		submatches := commitRefRegex.FindStringSubmatch(match)
302+		if len(submatches) < 2 {
303+			return match
304+		}
305+		
306+		fullHash := submatches[1]
307+		shortHash := getShortID(fullHash)
308+		commitURL := c.getCommitURL(fullHash)
309+		
310+		// Return the linked version
311+		return fmt.Sprintf(`Fixed in commit <a href="%s">%s</a>`, commitURL, shortHash)
312+	})
313+	
314+	return template.HTML(result)
315+}
316+```
317+
318+**Step 2: Apply transformation in loadIssues**
319+
320+In the `loadIssues` function, apply the transformation to both description and comments:
321+
322+Find the section where description is set (around line 118-122):
323+```go
324+// Get description from first comment
325+var description template.HTML
326+if len(snap.Comments) > 0 {
327+	description = c.renderMarkdown(snap.Comments[0].Message)
328+	description = c.transformCommitRefs(description)  // ADD THIS LINE
329+}
330+```
331+
332+Find the section where comments are built (around lines 106-116):
333+```go
334+comments = append(comments, CommentData{
335+	Author:        comment.Author.Name(),
336+	CreatedAt:     comment.FormatTime(),
337+	CreatedAtISO:  createdAtTime.UTC().Format(time.RFC3339),
338+	CreatedAtDisp: formatDateForDisplay(createdAtTime),
339+	HumanDate:     comment.FormatTimeRel(),
340+	Body:          c.transformCommitRefs(c.renderMarkdown(comment.Message)),  // MODIFY THIS LINE
341+})
342+```
343+
344+**Step 3: Commit**
345+
346+```bash
347+jj commit -m "feat(issues): transform commit references to links in issue pages"
348+```
349+
350+---
351+
352+## Task 4: Update Templates for HTML Comments
353+
354+**Files:**
355+- Modify: `html/issue_detail.page.tmpl`
356+
357+**Step 1: Update comment body rendering**
358+
359+Ensure comments are rendered as HTML (they should already be, but verify):
360+
361+In `html/issue_detail.page.tmpl`, find the comment body lines:
362+```html
363+<div class="issue-comment__body">{{.Issue.Description}}</div>
364+```
365+
366+And:
367+```html
368+<div class="issue-comment__body">{{.Body}}</div>
369+```
370+
371+These should already work since the content is `template.HTML` type.
372+
373+**Step 2: Add CSS for commit links in comments**
374+
375+In `static/pgit.css`, add styling for commit links within comments:
376+
377+```css
378+/* Commit links within issue comments */
379+.issue-comment__body a[href*="/commits/"] {
380+	font-family: monospace;
381+	background: var(--border);
382+	padding: 2px 6px;
383+	border-radius: 3px;
384+	text-decoration: none;
385+}
386+
387+.issue-comment__body a[href*="/commits/"]:hover {
388+	background: var(--link-color);
389+	color: var(--bg-color);
390+}
391+```
392+
393+**Step 3: Commit**
394+
395+```bash
396+jj commit -m "feat(css): style commit links in issue comments"
397+```
398+
399+---
400+
401+## Task 5: Build and Test
402+
403+**Files:**
404+- Run: Build and test commands
405+
406+**Step 1: Build the project**
407+
408+```bash
409+go build -o pgit .
410+```
411+
412+**Step 2: Test hook installation**
413+
414+```bash
415+# Test install
416+./pgit install-hook --repo=./testdata.repo
417+
418+# Verify hook was installed
419+ls -la testdata.repo/.git/hooks/commit-msg
420+
421+# Test uninstall
422+./pgit uninstall-hook --repo=./testdata.repo
423+
424+# Verify hook was removed
425+ls -la testdata.repo/.git/hooks/commit-msg  # should fail
426+```
427+
428+**Step 3: Test the hook manually**
429+
430+```bash
431+# Install hook
432+./pgit install-hook --repo=./testdata.repo
433+
434+# Create a test commit referencing an issue (use an issue that exists)
435+cd testdata.repo
436+echo "test" >> testfile
437+git add testfile
438+git commit -m "fixes #872a52d - testing the hook"
439+
440+# Verify the issue was updated
441+git bug show 872a52d
442+```
443+
444+**Step 4: Test link transformation**
445+
446+```bash
447+# Generate the static site
448+./pgit --revs main --out ./test-output --issues --repo ./testdata.repo
449+
450+# Check an issue page for commit links
451+cat ./test-output/issues/872a52d*.html | grep -A2 -B2 "Fixed in commit"
452+# Should show an <a> tag linking to the commit
453+```
454+
455+**Step 5: Commit**
456+
457+```bash
458+jj commit -m "build: verify hook installation and link transformation work"
459+```
460+
461+---
462+
463+## Task 6: Update Documentation
464+
465+**Files:**
466+- Modify: `README.md`
467+
468+**Step 1: Add commit-msg hook section**
469+
470+Add to README.md after the git-bug integration section:
471+
472+```markdown
473+## commit-msg hook
474+
475+pgit includes a commit-msg hook that automatically closes git-bug issues when
476+you reference them in commit messages.
477+
478+### Installation
479+
480+Install the hook in your repository:
481+
482+```bash
483+./pgit install-hook --repo=/path/to/your/repo
484+```
485+
486+### Usage
487+
488+Once installed, the hook will automatically:
489+
490+1. Parse your commit messages for issue references
491+2. Add a comment "Fixed in commit [hash]" to the referenced issue
492+3. Close the git-bug issue
493+
494+Supported keywords (case-insensitive):
495+- `fix`, `fixes`, `fixed`
496+- `close`, `closes`, `closed`
497+- `resolve`, `resolves`, `resolved`
498+
499+Example commit messages:
500+
501+```bash
502+git commit -m "fixes #872a52d - resolved the memory leak"
503+git commit -m "This commit closes #abc1234 and resolves #def5678"
504+```
505+
506+### How it works
507+
508+When you commit with an issue reference:
509+
510+1. The hook extracts the 7-character issue ID (e.g., `872a52d`)
511+2. It runs `git-bug bug comment new [ID] -m "Fixed in commit [hash]"`
512+3. It runs `git-bug bug close [ID]` to close the issue
513+
514+When pgit generates the static site:
515+
516+1. Issue comments are scanned for "Fixed in commit [hash]" patterns
517+2. These are transformed into clickable links to the commit pages
518+3. The full 40-character hash is linked, but displayed as a 7-character short hash
519+
520+### Uninstallation
521+
522+Remove the hook:
523+
524+```bash
525+./pgit uninstall-hook --repo=/path/to/your/repo
526+```
527+
528+This will restore any previous commit-msg hook if one existed.
529+```
530+
531+**Step 2: Update help/usage output**
532+
533+Consider updating the help output in main.go to mention the new commands:
534+
535+```go
536+func printUsage() {
537+	fmt.Fprintln(os.Stderr, "Usage: pgit [options]")
538+	fmt.Fprintln(os.Stderr, "")
539+	fmt.Fprintln(os.Stderr, "Commands:")
540+	fmt.Fprintln(os.Stderr, "  (no command)     Generate static site")
541+	fmt.Fprintln(os.Stderr, "  install-hook     Install the commit-msg hook")
542+	fmt.Fprintln(os.Stderr, "  uninstall-hook   Uninstall the commit-msg hook")
543+	fmt.Fprintln(os.Stderr, "")
544+	fmt.Fprintln(os.Stderr, "Options:")
545+	flag.PrintDefaults()
546+}
547+```
548+
549+**Step 3: Commit**
550+
551+```bash
552+jj commit -m "docs: add commit-msg hook documentation"
553+```
554+
555+---
556+
557+## Summary
558+
559+After completing all tasks, the project will have:
560+
561+1. **Simple bash hook** (`hooks/commit-msg.bash`) - no compilation needed
562+2. **Install/uninstall commands** built into the main `pgit` binary
563+3. **Link transformation** during static site generation
564+4. **Zero configuration** - uses existing git-bug CLI
565+
566+**Files Created:**
567+- `hooks/commit-msg.bash` - The bash hook script
568+
569+**Files Modified:**
570+- `main.go` - Added install-hook/uninstall-hook commands and embed directive
571+- `issues.go` - Added `transformCommitRefs` function
572+- `html/issue_detail.page.tmpl` - (verify no changes needed)
573+- `static/pgit.css` - Added commit link styling
574+- `README.md` - Added documentation
575+
576+**Usage:**
577+```bash
578+# Install the hook
579+./pgit install-hook --repo=/path/to/repo
580+
581+# Commits with issue references auto-close issues
582+git commit -m "fixes #872a52d - fixed the bug"
583+
584+# Uninstall
585+./pgit uninstall-hook --repo=/path/to/repo
586+```
587+
588+**Advantages of this approach:**
589+- **Simple**: Just a bash script, no Go code for the hook
590+- **No dependencies**: Uses existing `git-bug` CLI
591+- **No URL configuration**: Plain text in comments, links added at build time
592+- **Maintainable**: 3 small tasks vs 9 complex tasks
593+- **Portable**: Bash is available everywhere git is
A hooks/post-commit.bash
+64, -0
 1@@ -0,0 +1,64 @@
 2+#!/bin/bash
 3+# pgit post-commit hook
 4+# Automatically closes git-bug issues referenced in commit messages
 5+#
 6+# Usage: Install as .git/hooks/post-commit
 7+
 8+set -e
 9+
10+# Check if git-bug is available
11+if ! command -v git-bug &> /dev/null; then
12+	echo "Warning: git-bug not found in PATH, skipping issue updates" >&2
13+	exit 0
14+fi
15+
16+# Get the commit hash (now correct - post-commit runs after commit)
17+COMMIT_HASH=$(git rev-parse HEAD)
18+if [ -z "$COMMIT_HASH" ]; then
19+	echo "Warning: Could not determine commit hash, skipping issue updates" >&2
20+	exit 0
21+fi
22+
23+# Get the commit message from the just-created commit
24+COMMIT_MSG=$(git log -1 --pretty=%B)
25+
26+# Extract issue references using regex
27+# Matches: fixes #872a52d, close #abc1234, resolved #def5678, etc.
28+# Case insensitive, captures the 7-char hex ID
29+ISSUE_REFS=$(echo "$COMMIT_MSG" | grep -oEi '\b(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\b[[:space:]]*#[a-f0-9]{7}\b' || true)
30+
31+if [ -z "$ISSUE_REFS" ]; then
32+	# No issue references found
33+	exit 0
34+fi
35+
36+# Process each reference
37+echo "$ISSUE_REFS" | while read -r ref; do
38+	# Extract the issue ID (the hex part after #)
39+	issue_id=$(echo "$ref" | grep -oE '#[a-f0-9]{7}\b' | sed 's/^#//i' | tr '[:upper:]' '[:lower:]')
40+
41+	if [ -z "$issue_id" ]; then
42+		continue
43+	fi
44+
45+	echo "Processing git-bug issue #$issue_id..." >&2
46+
47+	# Add comment with commit reference
48+	comment="Fixed in commit $COMMIT_HASH"
49+
50+	# Try to add comment - don't fail the commit if this fails
51+	if git-bug bug comment new "$issue_id" -m "$comment" 2>/dev/null; then
52+		echo "  Added comment to issue #$issue_id" >&2
53+	else
54+		echo "  Warning: Failed to add comment to issue #$issue_id" >&2
55+	fi
56+
57+	# Try to close the issue - don't fail the commit if this fails
58+	if git-bug bug status close "$issue_id" 2>/dev/null; then
59+		echo "  Closed issue #$issue_id" >&2
60+	else
61+		echo "  Warning: Failed to close issue #$issue_id" >&2
62+	fi
63+done
64+
65+exit 0
M html/commit.page.tmpl
+1, -1
1@@ -4,7 +4,7 @@
2 
3 {{define "content"}}
4   <div>
5-    <pre class="commit-message">{{.Commit.Message}}</pre>
6+    <div class="commit-message">{{.Commit.Message}}</div>
7 
8     <div class="metadata">
9       <div class="metadata__label">commit</div>
M html/log.page.tmpl
+2, -2
 1@@ -10,14 +10,14 @@
 2       <div class="commit-item">
 3         <div class="commit-item__header">
 4           <div class="commit-item__content">
 5-            <pre class="commit-item__message">{{.Message}}</pre>
 6+            <div class="commit-item__message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-git-commit-vertical-icon lucide-git-commit-vertical"><path d="M12 3v6"/><circle cx="12" cy="12" r="3"/><path d="M12 15v6"/></svg><a href="{{.URL}}">{{.Message}}</a></div>
 7             <div class="commit-item__meta">
 8               <span class="font-bold">{{.AuthorStr}}</span>
 9               <span>&nbsp;&centerdot;&nbsp;</span>
10               <span>{{.WhenStr}}</span>
11             </div>
12           </div>
13-          
14+
15           <div class="commit-item__actions">
16             <a href="{{.URL}}" class="commit-item__hash">{{.ShortID}}</a>
17             {{if .Refs}}
M issues.go
+31, -1
 1@@ -5,12 +5,41 @@ import (
 2 	"html/template"
 3 	"net/url"
 4 	"path/filepath"
 5+	"regexp"
 6 	"time"
 7 
 8 	"github.com/git-bug/git-bug/entities/bug"
 9 	"github.com/git-bug/git-bug/repository"
10 )
11 
12+// commitRefRegex matches "Fixed in commit [40-char-hash]" or similar patterns
13+// Captures the full 40-character commit hash
14+// (?i) makes it case-insensitive, \b ensures word boundaries
15+var commitRefRegex = regexp.MustCompile(`(?i)\bFixed in commit ([a-f0-9]{40})\b`)
16+
17+// transformCommitRefs converts plain text commit references in issue comments
18+// into clickable links using pgit's URL scheme
19+func (c *Config) transformCommitRefs(content template.HTML) template.HTML {
20+	contentStr := string(content)
21+
22+	result := commitRefRegex.ReplaceAllStringFunc(contentStr, func(match string) string {
23+		// Extract hash by position: the hash is always exactly 40 hex chars at the end
24+		// This avoids the double regex execution (FindStringSubmatch inside ReplaceAllStringFunc)
25+		if len(match) < 40 {
26+			return match
27+		}
28+		hash := match[len(match)-40:]
29+		prefix := match[:len(match)-40]
30+		shortHash := getShortID(hash)
31+		commitURL := c.getCommitURL(hash)
32+
33+		// Return the linked version preserving original casing
34+		return fmt.Sprintf(`%s<a href="%s">%s</a>`, prefix, commitURL, shortHash)
35+	})
36+
37+	return template.HTML(result)
38+}
39+
40 // IssueData represents a git-bug issue for templates
41 type IssueData struct {
42 	ID            string
43@@ -111,7 +140,7 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
44 				CreatedAtISO:  createdAtTime.UTC().Format(time.RFC3339),
45 				CreatedAtDisp: formatDateForDisplay(createdAtTime),
46 				HumanDate:     comment.FormatTimeRel(),
47-				Body:          c.renderMarkdown(comment.Message),
48+				Body:          c.transformCommitRefs(c.renderMarkdown(comment.Message)),
49 			})
50 		}
51 
52@@ -119,6 +148,7 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
53 		var description template.HTML
54 		if len(snap.Comments) > 0 {
55 			description = c.renderMarkdown(snap.Comments[0].Message)
56+			description = c.transformCommitRefs(description)
57 		}
58 
59 		fullID := b.Id().String()
M main.go
+139, -0
  1@@ -35,6 +35,8 @@ import (
  2 )
  3 
  4 //go:embed html/*.tmpl
  5+//go:embed static/*
  6+//go:embed hooks/*.bash
  7 var embedFS embed.FS
  8 
  9 //go:embed static/*
 10@@ -1355,6 +1357,30 @@ func main() {
 11 	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
 12 	var issuesFlag = flag.Bool("issues", false, "enable git-bug issue generation")
 13 
 14+	// Pre-parse for hook commands and their --repo flag
 15+	// This is needed because flag.Parse() stops at non-flag args
 16+	repoPathForHook := "."
 17+	for i, arg := range os.Args[1:] {
 18+		if arg == "install-hook" || arg == "uninstall-hook" {
 19+			// Look for --repo flag in remaining args (after the command)
 20+			// i is the index in os.Args[1:], so actual index in os.Args is i+1
 21+			// We need to check os.Args[i+2:] for flags after the command
 22+			for j := i + 2; j < len(os.Args); j++ {
 23+				if strings.HasPrefix(os.Args[j], "--repo=") {
 24+					repoPathForHook = strings.TrimPrefix(os.Args[j], "--repo=")
 25+					break
 26+				}
 27+			}
 28+			// Execute hook command
 29+			if arg == "install-hook" {
 30+				installHook(repoPathForHook)
 31+			} else {
 32+				uninstallHook(repoPathForHook)
 33+			}
 34+			return
 35+		}
 36+	}
 37+
 38 	flag.Parse()
 39 
 40 	out, err := filepath.Abs(*outdir)
 41@@ -1435,3 +1461,116 @@ func main() {
 42 	url := filepath.Join("/", "index.html")
 43 	config.Logger.Info("root url", "url", url)
 44 }
 45+
 46+// installHook installs the post-commit hook into the repository
 47+func installHook(repoPath string) {
 48+	// Convert to absolute path
 49+	absRepoPath, err := filepath.Abs(repoPath)
 50+	if err != nil {
 51+		fmt.Fprintf(os.Stderr, "Error: Could not resolve repo path: %v\n", err)
 52+		os.Exit(1)
 53+	}
 54+
 55+	// Find the embedded hook script
 56+	hookContent, err := embedFS.ReadFile("hooks/post-commit.bash")
 57+	if err != nil {
 58+		fmt.Fprintf(os.Stderr, "Error: Could not read embedded hook: %v\n", err)
 59+		os.Exit(1)
 60+	}
 61+
 62+	// Determine hooks directory: .git/hooks for normal repos, hooks/ for bare repos
 63+	hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
 64+	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
 65+		// Check for bare repo (hooks/ at root)
 66+		bareHooksDir := filepath.Join(repoPath, "hooks")
 67+		if _, err := os.Stat(bareHooksDir); err == nil {
 68+			hooksDir = bareHooksDir
 69+			fmt.Printf("Detected bare repository, installing to %s\n", hooksDir)
 70+		}
 71+	}
 72+
 73+	postCommitHook := filepath.Join(hooksDir, "post-commit")
 74+
 75+	// Ensure hooks directory exists
 76+	if err := os.MkdirAll(hooksDir, 0755); err != nil {
 77+		fmt.Fprintf(os.Stderr, "Error: Could not create hooks directory: %v\n", err)
 78+		os.Exit(1)
 79+	}
 80+
 81+	// Check if a post-commit hook already exists
 82+	if _, err := os.Stat(postCommitHook); err == nil {
 83+		// Backup existing hook
 84+		backupPath := postCommitHook + ".backup"
 85+		if err := os.Rename(postCommitHook, backupPath); err != nil {
 86+			fmt.Fprintf(os.Stderr, "Error: Failed to backup existing hook: %v\n", err)
 87+			os.Exit(1)
 88+		}
 89+		fmt.Printf("Backed up existing post-commit hook to %s\n", backupPath)
 90+	}
 91+
 92+	// Write the hook
 93+	if err := os.WriteFile(postCommitHook, hookContent, 0755); err != nil {
 94+		fmt.Fprintf(os.Stderr, "Error: Failed to write hook: %v\n", err)
 95+		os.Exit(1)
 96+	}
 97+
 98+	fmt.Printf("Installed post-commit hook to %s\n", postCommitHook)
 99+	fmt.Println("Commits with issue references (e.g., 'fixes #872a52d') will now automatically close git-bug issues")
100+}
101+
102+// uninstallHook removes the pgit post-commit hook
103+func uninstallHook(repoPath string) {
104+	// Convert to absolute path
105+	absRepoPath, err := filepath.Abs(repoPath)
106+	if err != nil {
107+		fmt.Fprintf(os.Stderr, "Error: Could not resolve repo path: %v\n", err)
108+		os.Exit(1)
109+	}
110+
111+	// Determine hooks directory: .git/hooks for normal repos, hooks/ for bare repos
112+	hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
113+	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
114+		// Check for bare repo (hooks/ at root)
115+		bareHooksDir := filepath.Join(repoPath, "hooks")
116+		if _, err := os.Stat(bareHooksDir); err == nil {
117+			hooksDir = bareHooksDir
118+		}
119+	}
120+
121+	postCommitHook := filepath.Join(hooksDir, "post-commit")
122+
123+	// Check if hook exists
124+	data, err := os.ReadFile(postCommitHook)
125+	if err != nil {
126+		if os.IsNotExist(err) {
127+			fmt.Println("No post-commit hook found")
128+			return
129+		}
130+		fmt.Fprintf(os.Stderr, "Error: Failed to read hook: %v\n", err)
131+		os.Exit(1)
132+	}
133+
134+	// Check if it's our hook
135+	if !strings.Contains(string(data), "pgit post-commit hook") {
136+		fmt.Fprintf(os.Stderr, "Error: post-commit hook is not a pgit hook (not removing)\n")
137+		os.Exit(1)
138+	}
139+
140+	// Remove the hook
141+	if err := os.Remove(postCommitHook); err != nil {
142+		fmt.Fprintf(os.Stderr, "Error: Failed to remove hook: %v\n", err)
143+		os.Exit(1)
144+	}
145+
146+	// Restore backup if exists
147+	backupPath := postCommitHook + ".backup"
148+	if _, err := os.Stat(backupPath); err == nil {
149+		if err := os.Rename(backupPath, postCommitHook); err != nil {
150+			fmt.Fprintf(os.Stderr, "Error: Failed to restore backup: %v\n", err)
151+			os.Exit(1)
152+		}
153+		fmt.Printf("Restored backup hook from %s\n", backupPath)
154+	}
155+
156+	fmt.Println("Uninstalled pgit post-commit hook")
157+}
M static/pgit.css
+53, -18
  1@@ -295,6 +295,44 @@ sup {
  2 
  3 /* ==== SITE COMPONENTS ==== */
  4 
  5+/* ==== VIEW TRANSITIONS ==== */
  6+@view-transition {
  7+  navigation: auto;
  8+}
  9+
 10+/* Smooth cross-fade for view transitions */
 11+::view-transition-old(root) {
 12+  animation: fade-out 150ms ease-out forwards;
 13+}
 14+
 15+::view-transition-new(root) {
 16+  animation: fade-in 150ms ease-in forwards;
 17+}
 18+
 19+@keyframes fade-out {
 20+  from { opacity: 1; }
 21+  to { opacity: 0; }
 22+}
 23+
 24+@keyframes fade-in {
 25+  from { opacity: 0; }
 26+  to { opacity: 1; }
 27+}
 28+
 29+/* Prevent flash during HTMX swaps */
 30+.htmx-swapping {
 31+  opacity: 1;
 32+}
 33+
 34+.htmx-settling {
 35+  animation: htmx-fade-in 100ms ease-in;
 36+}
 37+
 38+@keyframes htmx-fade-in {
 39+  from { opacity: 0.8; }
 40+  to { opacity: 1; }
 41+}
 42+
 43 /* Site Header */
 44 .site-header {
 45   display: flex;
 46@@ -318,7 +356,7 @@ sup {
 47   margin-top: var(--grid-height);
 48 }
 49 
 50-.site-header__desc > p {
 51+.site-header__desc>p {
 52   padding: 0;
 53   margin: 0;
 54 }
 55@@ -471,7 +509,6 @@ sup {
 56 .commit-list {
 57   display: flex;
 58   flex-direction: column;
 59-  gap: var(--line-height);
 60 }
 61 
 62 .commit-list__count {
 63@@ -480,20 +517,11 @@ sup {
 64 
 65 /* Commit Item */
 66 .commit-item {
 67-  border-bottom: 1px solid var(--grey);
 68   transition: background-color 0.2s ease;
 69-  padding: var(--grid-height) 0.5rem;
 70+  padding: 0 0.5rem;
 71   margin: 0 -0.5rem;
 72-  border-radius: 4px;
 73 }
 74 
 75-.commit-item:hover {
 76-  background-color: var(--pre);
 77-}
 78-
 79-.commit-item:last-child {
 80-  border-bottom: none;
 81-}
 82 
 83 .commit-item__header {
 84   display: flex;
 85@@ -509,11 +537,22 @@ sup {
 86 
 87 .commit-item__message {
 88   margin: 0;
 89+  display: inline-block;
 90   white-space: break-spaces;
 91   font-weight: bold;
 92   font-size: 1rem;
 93   line-height: var(--line-height);
 94-  text-transform: uppercase;
 95+  transform: translateX(-1.25rem);
 96+}
 97+
 98+.commit-item__message a {
 99+  color: var(--text-color);
100+}
101+
102+.commit-item__message>svg {
103+  width: 1rem;
104+  margin-right: 0.25rem;
105+  transform: translateY(25%);
106 }
107 
108 .commit-item__meta {
109@@ -522,14 +561,12 @@ sup {
110   gap: 0.25rem;
111   font-size: 0.8rem;
112   color: var(--grey-light);
113-  margin-top: var(--grid-height);
114 }
115 
116 .commit-item__actions {
117   display: flex;
118   flex-direction: column;
119   align-items: flex-end;
120-  gap: 0.25rem;
121 }
122 
123 .commit-item__hash {
124@@ -549,10 +586,8 @@ sup {
125 
126 .commit-item__refs {
127   display: flex;
128-  gap: 0.25rem;
129   flex-wrap: wrap;
130   justify-content: flex-end;
131-  margin-top: var(--grid-height);
132 }
133 
134 .commit-item__ref {
135@@ -1344,7 +1379,7 @@ sup {
136   border: 1px solid var(--border);
137   border-radius: 4px;
138   margin-bottom: var(--grid-height);
139-  background: var(--pre);
140+  background: var(--background);
141 }
142 
143 .issue-comment__header {
M testdata/clocks/bugs-create
+1, -1
1@@ -1 +1 @@
2-3
3+5
M testdata/clocks/bugs-edit
+1, -1
1@@ -1 +1 @@
2-11
3+21
A testdata/objects/12/a47067ab3b791b443b9f9df6003ff5793cf2f9
+0, -0
A testdata/objects/14/ed4b958b9ec81750f4d262ee17b60ce2f22a16
+0, -0
A testdata/objects/15/f1ce58d0a5ef2a96e6fc00d8079b3f591389f8
+0, -0
A testdata/objects/1a/29bae1179b55fdc01c66ea6f4b9d3af9f231a5
+0, -0
A testdata/objects/29/bc0bf51ae33faf8f82bf6bfb6318ccf7f59bca
+0, -0
A testdata/objects/31/b1abd3b8c609f20e5a2cfadc47f92089c707db
+0, -0
A testdata/objects/45/324925fd56c845ca65b989b6cf5f2a492bf9c7
+0, -0
A testdata/objects/4c/96db3fa06b5dc73acaf5fb155549c7d6811bb3
+0, -0
A testdata/objects/56/96b5c740ee1743e5ffc61a977ab886037d3f88
+0, -0
A testdata/objects/59/166e87fbcf3b031626e09f50b773f68d2f9d5b
+0, -0
A testdata/objects/5c/8e06f6feba3ef8b36d3e50f5b3f1cc8d4d26d0
+0, -0
A testdata/objects/65/82f01c6786099569df1291fae9aed5d3bae226
+0, -0
A testdata/objects/69/2280483924a8b6d83c81eec71fb66ec56da9dd
+0, -0
A testdata/objects/6b/045bbefb0f27ea3972bf1ea5ec74b17111586d
+0, -0
A testdata/objects/6b/387a6baefbbaad32cdf131575e3e3d389903fb
+0, -0
A testdata/objects/6c/e5156ffc70331a326d0354e222fa08fd1867d7
+0, -0
A testdata/objects/6d/a6a43497ae5123e57335cbae03dc5707119a2c
+0, -0
A testdata/objects/70/7059d85776e5887c94f68ecdf40b3fc55effae
+0, -0
A testdata/objects/77/12e5c0dc40157b1c4cb65cf449d03d3f7e7309
+0, -0
A testdata/objects/7a/cc858de80da475f99b8463110f835a27ae72d9
+0, -0
A testdata/objects/7b/3a2edae3cef647463926af583c315028fa0762
+0, -0
A testdata/objects/7b/5519ed4f91378a0cb219fe891082a2f1e2eb20
+0, -0
A testdata/objects/7f/5cddf8d5b0960ed35b1a73f8c236b2c49a76d0
+0, -0
A testdata/objects/83/086721c6cbbc3bd57a6235153bd615de70b245
+0, -0
A testdata/objects/94/d9f67211d31bedbd8088cf94145f1f7b2e7454
+0, -0
A testdata/objects/a5/2342756b2d0334e79408e33c638ab69a0c028f
+0, -0
A testdata/objects/a5/8e1bc415006b636cd98dd11b9af1ffdb9e4a62
+0, -0
A testdata/objects/ab/128964d7c9821627ae9b02e1b19295d7db0295
+0, -0
A testdata/objects/c0/25a70412347b9c6be2358296ea9ae61cd2459b
+0, -0
A testdata/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
+0, -0
A testdata/objects/dc/cd54daf89615486cf062c37757f1cb05ac24bf
+0, -0
A testdata/objects/e3/70cff752901b93070978fced4b752288589fef
+0, -0
A testdata/objects/e4/3c9f4250a1ec82728d552d0db167d096a3cdd1
+0, -0
A testdata/objects/e6/3ffce0b77669968b153dcfc52630cbff4b410f
+0, -0
A testdata/objects/ea/8b57217f6981293a72b0f3d13e16965ca7a6d3
+0, -0
A testdata/objects/fc/3116b0a9a1407025eac4ea91b07bffb1971b71
+0, -0
A testdata/objects/fd/6154317896d13481483dcea50f22b05c4ff86d
+0, -0
M testdata/refs/bugs/872a52d8a57756003bb29a33a1527824a1058f7e1fbb764b4eb24f9fad408c75
+1, -1
1@@ -1 +1 @@
2-62216754e378ef45e78db0213a1b1957996747f5
3+dccd54daf89615486cf062c37757f1cb05ac24bf
A testdata/refs/bugs/dbb6f9a0d2089b6cc06064f8ae975dbda39889e210e5d9c61fb0866c04bc18ae
+1, -0
1@@ -0,0 +1 @@
2+e43c9f4250a1ec82728d552d0db167d096a3cdd1
A testdata/refs/bugs/f78855e210156d3875005c3d7880c3b50d6218beecbb355af682812d0700b210
+1, -0
1@@ -0,0 +1 @@
2+7acc858de80da475f99b8463110f835a27ae72d9
M testdata/refs/heads/main
+1, -1
1@@ -1 +1 @@
2-b51ecb50102b1b7b73ff2c7c684278d6c00b507a
3+6b045bbefb0f27ea3972bf1ea5ec74b17111586d