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
+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
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+```
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
+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
+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>
+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> · </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}}
+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+}
+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 {
+1,
-1
1@@ -1 +1 @@
2-3
3+5
+1,
-1
1@@ -1 +1 @@
2-11
3+21
1@@ -1 +1 @@
2-62216754e378ef45e78db0213a1b1957996747f5
3+dccd54daf89615486cf062c37757f1cb05ac24bf
1@@ -0,0 +1 @@
2+e43c9f4250a1ec82728d552d0db167d096a3cdd1
1@@ -0,0 +1 @@
2+7acc858de80da475f99b8463110f835a27ae72d9
+1,
-1
1@@ -1 +1 @@
2-b51ecb50102b1b7b73ff2c7c684278d6c00b507a
3+6b045bbefb0f27ea3972bf1ea5ec74b17111586d