harmonize styles for commit information, hover states, lists
11 files changed,  +43, -4070
M .gitignore
+1, -0
1@@ -4,3 +4,4 @@ pgit
2 public/
3 testdata.site/
4 testdata.repo/
5+docs/plans/
D docs/plans/2025-01-07-git-bug-integration.md
+0, -939
  1@@ -1,939 +0,0 @@
  2-# 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:** Integrate git-bug issue tracking into pgit static site generator, creating issue list pages and individual issue detail pages.
  7-
  8-**Architecture:** Use git-bug's Go library (cache and entities/bug packages) to read issues from the repository at build time, filter and categorize them, then generate static HTML pages using Go templates.
  9-
 10-**Tech Stack:** Go, git-bug library, Go templates (existing pgit template system)
 11-
 12----
 13-
 14-## Task 1: Add git-bug Dependency
 15-
 16-**Files:**
 17-- Modify: `go.mod`
 18-- Run: `go mod tidy`
 19-
 20-**Step 1: Add git-bug dependency**
 21-
 22-Run: `go get github.com/git-bug/[email protected]`
 23-
 24-**Step 2: Verify go.mod updated**
 25-
 26-Check that `github.com/git-bug/git-bug v0.10.1` appears in go.mod
 27-
 28-**Step 3: Commit**
 29-
 30-```bash
 31-jj commit -m "deps: add git-bug library for issue tracking"
 32-```
 33-
 34----
 35-
 36-## Task 2: Create Issue Data Structures
 37-
 38-**Files:**
 39-- Create: `issues.go` (new file)
 40-
 41-**Step 1: Create issue data types**
 42-
 43-```go
 44-package main
 45-
 46-import (
 47-	"html/template"
 48-	"time"
 49-)
 50-
 51-// IssueData represents a git-bug issue for templates
 52-type IssueData struct {
 53-	ID           string
 54-	FullID       string
 55-	Title        string
 56-	Status       string // "open" or "closed"
 57-	Author       string
 58-	CreatedAt    time.Time
 59-	HumanDate    string
 60-	Labels       []string
 61-	CommentCount int // excludes original description
 62-	Description  template.HTML
 63-	Comments     []CommentData
 64-	URL          template.URL
 65-}
 66-
 67-// CommentData represents a comment on an issue
 68-type CommentData struct {
 69-	Author    string
 70-	CreatedAt time.Time
 71-	HumanDate string
 72-	Body      template.HTML
 73-}
 74-
 75-// IssuesListPageData for list pages (open, closed, by label)
 76-type IssuesListPageData struct {
 77-	*PageData
 78-	Filter      string // "open", "closed", or "label"
 79-	OpenCount   int
 80-	ClosedCount int
 81-	Issues      []*IssueData
 82-	Label       string   // set when Filter == "label"
 83-	AllLabels   []string // all unique labels from open issues
 84-}
 85-
 86-// IssueDetailPageData for individual issue pages
 87-type IssueDetailPageData struct {
 88-	*PageData
 89-	Issue *IssueData
 90-}
 91-
 92-// Active returns the active navigation item
 93-func (i *IssuesListPageData) Active() string { return "issues" }
 94-func (i *IssueDetailPageData) Active() string { return "issues" }
 95-```
 96-
 97-**Step 2: Commit**
 98-
 99-```bash
100-jj commit -m "feat: add issue data structures"
101-```
102-
103----
104-
105-## Task 3: Create Issue Loading Function
106-
107-**Files:**
108-- Modify: `issues.go` (add import and functions)
109-
110-**Step 1: Add imports and loading function**
111-
112-```go
113-import (
114-	"fmt"
115-	"net/url"
116-	"path/filepath"
117-	"strings"
118-
119-	"github.com/git-bug/git-bug/cache"
120-	"github.com/git-bug/git-bug/entities/bug"
121-	"github.com/git-bug/git-bug/repository"
122-)
123-
124-// loadIssues reads all issues from git-bug in the repository
125-func (c *Config) loadIssues() ([]*IssueData, error) {
126-	// Open the repository with git-bug's repo opener
127-	repo, err := repository.OpenGoGitRepo(c.RepoPath, nil, nil)
128-	if err != nil {
129-		return nil, fmt.Errorf("failed to open repository: %w", err)
130-	}
131-	
132-	// Create cache for efficient querying
133-	repoCache, err := cache.NewRepoCacheNoEvents(repo)
134-	if err != nil {
135-		return nil, fmt.Errorf("failed to create repo cache: %w", err)
136-	}
137-	defer repoCache.Close()
138-	
139-	// Read all bugs
140-	var issues []*IssueData
141-	bugCache := repoCache.Bugs()
142-	
143-	for streamedBug := range bugCache.ReadAll() {
144-		if streamedBug.Err != nil {
145-			c.Logger.Warn("failed to read bug", "error", streamedBug.Err)
146-			continue
147-		}
148-		
149-		b := streamedBug.Entity
150-		snap := b.Compile()
151-		
152-		// Count comments (excluding the original description)
153-		commentCount := 0
154-		for _, op := range snap.Operations {
155-			if _, ok := op.(*bug.AddCommentOp); ok {
156-				commentCount++
157-			}
158-		}
159-		
160-		// Build labels slice
161-		labels := make([]string, len(snap.Labels))
162-		copy(labels, snap.Labels)
163-		
164-		// Build comments
165-		var comments []CommentData
166-		for _, op := range snap.Operations {
167-			if addCommentOp, ok := op.(*bug.AddCommentOp); ok {
168-				comments = append(comments, CommentData{
169-					Author:    addCommentOp.Author.Name,
170-					CreatedAt: addCommentOp.Timestamp,
171-					HumanDate: humanizeTime(addCommentOp.Timestamp),
172-					Body:      renderMarkdown(addCommentOp.Message),
173-				})
174-			}
175-		}
176-		
177-		fullID := b.Id().String()
178-		issue := &IssueData{
179-			ID:           getShortID(fullID),
180-			FullID:       fullID,
181-			Title:        snap.Title,
182-			Status:       string(snap.Status),
183-			Author:       snap.Author.Name,
184-			CreatedAt:    snap.CreateTime,
185-			HumanDate:    humanizeTime(snap.CreateTime),
186-			Labels:       labels,
187-			CommentCount: commentCount,
188-			Description:  renderMarkdown(snap.Description),
189-			Comments:     comments,
190-			URL:          c.getIssueURL(fullID),
191-		}
192-		issues = append(issues, issue)
193-	}
194-	
195-	return issues, nil
196-}
197-
198-// getIssueURL generates the URL for an issue detail page
199-func (c *Config) getIssueURL(issueID string) template.URL {
200-	url := fmt.Sprintf("%sissues/%s.html", c.RootRelative, issueID)
201-	return template.URL(url)
202-}
203-
204-// getIssuesListURL generates URL for issues list pages
205-func (c *Config) getIssuesListURL(filter string, label string) template.URL {
206-	var path string
207-	switch filter {
208-	case "open", "closed":
209-		path = filepath.Join("issues", filter, "index.html")
210-	case "label":
211-		// URL-encode the label
212-		encodedLabel := url.PathEscape(label)
213-		path = filepath.Join("issues", "label", encodedLabel, "index.html")
214-	default:
215-		path = filepath.Join("issues", "open", "index.html")
216-	}
217-	return c.compileURL("/", path)
218-}
219-```
220-
221-**Step 2: Commit**
222-
223-```bash
224-jj commit -m "feat: add issue loading from git-bug"
225-```
226-
227----
228-
229-## Task 4: Create Issue List Template
230-
231-**Files:**
232-- Create: `html/issues_list.page.tmpl`
233-
234-**Step 1: Create the list template**
235-
236-```html
237-{{template "base" .}}
238-
239-{{define "title"}}{{if eq .Filter "label"}}Issues labeled "{{.Label}}"{{else if eq .Filter "closed"}}Closed Issues{{else}}Open Issues{{end}} - {{.Repo.RepoName}}{{end}}
240-
241-{{define "content"}}
242-<div class="issues-page">
243-  <div class="issues-header">
244-    <h1>
245-      {{if eq .Filter "label"}}
246-        Issues labeled "{{.Label}}"
247-      {{else if eq .Filter "closed"}}
248-        Closed Issues
249-      {{else}}
250-        Open Issues
251-      {{end}}
252-    </h1>
253-    
254-    <div class="issues-stats">
255-      {{if eq .Filter "open"}}
256-        <span class="stat active">{{.OpenCount}} Open</span>
257-        <a href="{{getIssuesListURL "closed" ""}}" class="stat">{{.ClosedCount}} Closed</a>
258-      {{else if eq .Filter "closed"}}
259-        <a href="{{getIssuesListURL "open" ""}}" class="stat">{{.OpenCount}} Open</a>
260-        <span class="stat active">{{.ClosedCount}} Closed</span>
261-      {{else}}
262-        <a href="{{getIssuesListURL "open" ""}}" class="stat">{{.OpenCount}} Open</a>
263-        <a href="{{getIssuesListURL "closed" ""}}" class="stat">{{.ClosedCount}} Closed</a>
264-        <span class="label-indicator">Label: {{.Label}}</span>
265-      {{end}}
266-    </div>
267-  </div>
268-
269-  {{if eq .Filter "open"}}
270-  <div class="issues-labels">
271-    <span>Filter by label:</span>
272-    {{range .AllLabels}}
273-      <a href="{{$.Repo.getIssuesListURL "label" .}}" class="label-link">{{.}}</a>
274-    {{end}}
275-  </div>
276-  {{end}}
277-
278-  <div class="issues-list">
279-    {{range .Issues}}
280-    <div class="issue-item">
281-      <div class="issue-item__main">
282-        <a href="{{.URL}}" class="issue-item__title">{{.Title}}</a>
283-        <div class="issue-item__meta">
284-          <span class="issue-id">#{{.ID}}</span>
285-          <span class="issue-status issue-status--{{.Status}}">{{.Status}}</span>
286-          {{if .Labels}}
287-          <span class="issue-labels">
288-            {{range .Labels}}<span class="issue-label">{{.}}</span>{{end}}
289-          </span>
290-          {{end}}
291-        </div>
292-      </div>
293-      <div class="issue-item__stats">
294-        <span class="issue-date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
295-        {{if gt .CommentCount 0}}
296-        <span class="issue-comments">{{.CommentCount}} comment{{if ne .CommentCount 1}}s{{end}}</span>
297-        {{end}}
298-      </div>
299-    </div>
300-    {{else}}
301-    <p class="no-issues">No issues found.</p>
302-    {{end}}
303-  </div>
304-</div>
305-{{end}}
306-```
307-
308-**Step 2: Commit**
309-
310-```bash
311-jj commit -m "feat: add issues list template"
312-```
313-
314----
315-
316-## Task 5: Create Issue Detail Template
317-
318-**Files:**
319-- Create: `html/issue_detail.page.tmpl`
320-
321-**Step 1: Create the detail template**
322-
323-```html
324-{{template "base" .}}
325-
326-{{define "title"}}{{.Issue.Title}} - Issue #{{.Issue.ID}} - {{.Repo.RepoName}}{{end}}
327-
328-{{define "content"}}
329-<div class="issue-detail">
330-  <div class="issue-detail__header">
331-    <h1>
332-      <span class="issue-status issue-status--{{.Issue.Status}}">{{.Issue.Status}}</span>
333-      {{.Issue.Title}}
334-    </h1>
335-    <div class="issue-detail__meta">
336-      <span class="issue-id">#{{.Issue.ID}}</span>
337-      <span class="issue-author">opened by {{.Issue.Author}}</span>
338-      <span class="issue-date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
339-    </div>
340-    {{if .Issue.Labels}}
341-    <div class="issue-detail__labels">
342-      <span>Labels:</span>
343-      {{range .Issue.Labels}}<span class="issue-label">{{.}}</span>{{end}}
344-    </div>
345-    {{end}}
346-  </div>
347-
348-  <div class="issue-description">
349-    <div class="issue-comment">
350-      <div class="issue-comment__header">
351-        <span class="issue-comment__author">{{.Issue.Author}}</span>
352-        <span class="issue-comment__date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
353-      </div>
354-      <div class="issue-comment__body">{{.Issue.Description}}</div>
355-    </div>
356-  </div>
357-
358-  {{if .Issue.Comments}}
359-  <div class="issue-comments">
360-    <h3>{{len .Issue.Comments}} Comment{{if ne (len .Issue.Comments) 1}}s{{end}}</h3>
361-    {{range .Issue.Comments}}
362-    <div class="issue-comment">
363-      <div class="issue-comment__header">
364-        <span class="issue-comment__author">{{.Author}}</span>
365-        <span class="issue-comment__date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
366-      </div>
367-      <div class="issue-comment__body">{{.Body}}</div>
368-    </div>
369-    {{end}}
370-  </div>
371-  {{end}}
372-</div>
373-{{end}}
374-```
375-
376-**Step 2: Commit**
377-
378-```bash
379-jj commit -m "feat: add issue detail template"
380-```
381-
382----
383-
384-## Task 6: Create Issue Page Generation Functions
385-
386-**Files:**
387-- Modify: `issues.go` (add generation functions)
388-
389-**Step 1: Add generation functions**
390-
391-```go
392-// writeIssueListPage generates an issues list page
393-func (c *Config) writeIssueListPage(data *PageData, filter string, label string, issues []*IssueData, openCount, closedCount int, allLabels []string) {
394-	c.Logger.Info("writing issues list", "filter", filter, "label", label, "count", len(issues))
395-	
396-	pageData := &IssuesListPageData{
397-		PageData:    data,
398-		Filter:      filter,
399-		OpenCount:   openCount,
400-		ClosedCount: closedCount,
401-		Issues:      issues,
402-		Label:       label,
403-		AllLabels:   allLabels,
404-	}
405-	
406-	var subdir string
407-	switch filter {
408-	case "open":
409-		subdir = "issues/open"
410-	case "closed":
411-		subdir = "issues/closed"
412-	case "label":
413-		encodedLabel := url.PathEscape(label)
414-		subdir = filepath.Join("issues/label", encodedLabel)
415-	}
416-	
417-	c.writeHtml(&WriteData{
418-		Filename: "index.html",
419-		Subdir:   subdir,
420-		Template: "html/issues_list.page.tmpl",
421-		Data:     pageData,
422-	})
423-}
424-
425-// writeIssueDetailPage generates an individual issue page
426-func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
427-	c.Logger.Info("writing issue detail", "id", issue.ID, "title", issue.Title)
428-	
429-	pageData := &IssueDetailPageData{
430-		PageData: data,
431-		Issue:    issue,
432-	}
433-	
434-	c.writeHtml(&WriteData{
435-		Filename: fmt.Sprintf("%s.html", issue.FullID),
436-		Subdir:   "issues",
437-		Template: "html/issue_detail.page.tmpl",
438-		Data:     pageData,
439-	})
440-}
441-
442-// writeIssues generates all issue-related pages
443-func (c *Config) writeIssues(pageData *PageData) error {
444-	// Load all issues from git-bug
445-	issues, err := c.loadIssues()
446-	if err != nil {
447-		return fmt.Errorf("failed to load issues: %w", err)
448-	}
449-	
450-	// If no issues, skip generation
451-	if len(issues) == 0 {
452-		c.Logger.Info("no git-bug issues found, skipping issue generation")
453-		return nil
454-	}
455-	
456-	c.Logger.Info("loaded issues", "count", len(issues))
457-	
458-	// Categorize issues
459-	var openIssues, closedIssues []*IssueData
460-	labelIssues := make(map[string][]*IssueData)
461-	allLabels := make(map[string]bool)
462-	
463-	for _, issue := range issues {
464-		if issue.Status == "open" {
465-			openIssues = append(openIssues, issue)
466-			// Collect labels from open issues
467-			for _, label := range issue.Labels {
468-				allLabels[label] = true
469-				labelIssues[label] = append(labelIssues[label], issue)
470-			}
471-		} else {
472-			closedIssues = append(closedIssues, issue)
473-		}
474-	}
475-	
476-	openCount := len(openIssues)
477-	closedCount := len(closedIssues)
478-	
479-	// Build sorted label list
480-	var sortedLabels []string
481-	for label := range allLabels {
482-		sortedLabels = append(sortedLabels, label)
483-	}
484-	sort.Strings(sortedLabels)
485-	
486-	// Generate individual issue pages for all issues
487-	for _, issue := range issues {
488-		c.writeIssueDetailPage(pageData, issue)
489-	}
490-	
491-	// Generate list pages
492-	c.writeIssueListPage(pageData, "open", "", openIssues, openCount, closedCount, sortedLabels)
493-	c.writeIssueListPage(pageData, "closed", "", closedIssues, openCount, closedCount, sortedLabels)
494-	
495-	// Generate label filter pages (only for open issues)
496-	for label, issues := range labelIssues {
497-		c.writeIssueListPage(pageData, "label", label, issues, openCount, closedCount, sortedLabels)
498-	}
499-	
500-	return nil
501-}
502-```
503-
504-**Step 2: Add import for sort**
505-
506-Add `sort` to imports in `issues.go`
507-
508-**Step 3: Commit**
509-
510-```bash
511-jj commit -m "feat: add issue page generation functions"
512-```
513-
514----
515-
516-## Task 7: Update Header Template for Issues Navigation
517-
518-**Files:**
519-- Modify: `html/header.partial.tmpl`
520-
521-**Step 1: Add Issues link to navigation**
522-
523-Add after the commits link (around line 16):
524-
525-```html
526-{{if .SiteURLs.IssuesURL}}{{if eq .Active "issues"}}<span class="active">issues</span>{{else}}<a href="{{.SiteURLs.IssuesURL}}">issues</a>{{end}}{{end}}
527-```
528-
529-**Step 2: Commit**
530-
531-```bash
532-jj commit -m "feat: add issues navigation link to header"
533-```
534-
535----
536-
537-## Task 8: Add IssuesURL to SiteURLs
538-
539-**Files:**
540-- Modify: `main.go` (SiteURLs struct and getURLs method)
541-
542-**Step 1: Add IssuesURL to SiteURLs struct**
543-
544-Around line 167, add:
545-
546-```go
547-type SiteURLs struct {
548-	HomeURL    template.URL
549-	CloneURL   template.URL
550-	SummaryURL template.URL
551-	RefsURL    template.URL
552-	IssuesURL  template.URL // new
553-}
554-```
555-
556-**Step 2: Add IssuesURL generation to getURLs method**
557-
558-Around line 609, add:
559-
560-```go
561-func (c *Config) getURLs() *SiteURLs {
562-	return &SiteURLs{
563-		HomeURL:    c.HomeURL,
564-		CloneURL:   c.CloneURL,
565-		RefsURL:    c.getRefsURL(),
566-		SummaryURL: c.getSummaryURL(),
567-		IssuesURL:  c.getIssuesURL(),
568-	}
569-}
570-
571-// getIssuesURL returns the URL for the issues page
572-func (c *Config) getIssuesURL() template.URL {
573-	url := c.RootRelative + "issues/open/index.html"
574-	return template.URL(url)
575-}
576-```
577-
578-**Step 3: Commit**
579-
580-```bash
581-jj commit -m "feat: add IssuesURL to site URLs"
582-```
583-
584----
585-
586-## Task 9: Add --issues CLI Flag and Integration
587-
588-**Files:**
589-- Modify: `main.go` (Config struct, flags, and writeRepo)
590-
591-**Step 1: Add Issues field to Config struct**
592-
593-Around line 34, add:
594-
595-```go
596-type Config struct {
597-	// ... existing fields ...
598-	Issues bool // enable git-bug issue generation
599-	// ... rest of fields ...
600-}
601-```
602-
603-**Step 2: Add --issues flag**
604-
605-Around line 1117, add:
606-
607-```go
608-var issuesFlag = flag.Bool("issues", false, "enable git-bug issue generation")
609-```
610-
611-**Step 3: Add Issues to config initialization**
612-
613-Around line 1146, add:
614-
615-```go
616-config := &Config{
617-	// ... existing fields ...
618-	Issues: *issuesFlag,
619-	// ... rest ...
620-}
621-```
622-
623-**Step 4: Integrate issue generation into writeRepo**
624-
625-Around line 740 (before `c.writeRefs`), add:
626-
627-```go
628-// Generate issue pages if enabled
629-if c.Issues {
630-	err := c.writeIssues(data)
631-	if err != nil {
632-		c.Logger.Warn("failed to write issues", "error", err)
633-	}
634-}
635-```
636-
637-**Step 5: Commit**
638-
639-```bash
640-jj commit -m "feat: add --issues flag and integrate issue generation"
641-```
642-
643----
644-
645-## Task 10: Add CSS Styling for Issues
646-
647-**Files:**
648-- Modify: `html/header.partial.tmpl` (add style block) or create separate CSS
649-
650-**Step 1: Add styles to header template**
651-
652-Add inside the `<style>` block or in a style tag in header.partial.tmpl:
653-
654-```html
655-<style>
656-/* Issues styles */
657-.issues-page {
658-  max-width: 1200px;
659-  margin: 0 auto;
660-  padding: 20px;
661-}
662-
663-.issues-header {
664-  margin-bottom: 20px;
665-  border-bottom: 1px solid var(--border);
666-  padding-bottom: 15px;
667-}
668-
669-.issues-header h1 {
670-  margin-bottom: 10px;
671-}
672-
673-.issues-stats {
674-  display: flex;
675-  gap: 15px;
676-  align-items: center;
677-}
678-
679-.issues-stats .stat {
680-  padding: 5px 10px;
681-  border-radius: 4px;
682-  text-decoration: none;
683-}
684-
685-.issues-stats .stat.active {
686-  background: var(--link-color);
687-  color: var(--bg-color);
688-}
689-
690-.issues-labels {
691-  margin: 15px 0;
692-  padding: 10px;
693-  background: rgba(255,255,255,0.05);
694-  border-radius: 4px;
695-}
696-
697-.issues-labels .label-link {
698-  margin-left: 10px;
699-  padding: 2px 8px;
700-  background: var(--border);
701-  border-radius: 3px;
702-  font-size: 0.9em;
703-}
704-
705-.issue-item {
706-  display: flex;
707-  justify-content: space-between;
708-  align-items: flex-start;
709-  padding: 15px;
710-  border-bottom: 1px solid var(--border);
711-}
712-
713-.issue-item:hover {
714-  background: rgba(255,255,255,0.02);
715-}
716-
717-.issue-item__title {
718-  font-size: 1.1em;
719-  font-weight: bold;
720-  margin-bottom: 5px;
721-  display: block;
722-}
723-
724-.issue-item__meta {
725-  display: flex;
726-  gap: 10px;
727-  align-items: center;
728-  font-size: 0.85em;
729-  color: var(--text-color);
730-  opacity: 0.7;
731-}
732-
733-.issue-status {
734-  padding: 2px 8px;
735-  border-radius: 3px;
736-  font-size: 0.85em;
737-  font-weight: bold;
738-}
739-
740-.issue-status--open {
741-  background: #28a745;
742-  color: white;
743-}
744-
745-.issue-status--closed {
746-  background: #6c757d;
747-  color: white;
748-}
749-
750-.issue-labels {
751-  display: flex;
752-  gap: 5px;
753-}
754-
755-.issue-label {
756-  padding: 2px 6px;
757-  background: var(--border);
758-  border-radius: 3px;
759-  font-size: 0.8em;
760-}
761-
762-.issue-item__stats {
763-  text-align: right;
764-  font-size: 0.85em;
765-  opacity: 0.7;
766-}
767-
768-.issue-comments {
769-  margin-left: 10px;
770-}
771-
772-/* Issue detail */
773-.issue-detail {
774-  max-width: 1000px;
775-  margin: 0 auto;
776-  padding: 20px;
777-}
778-
779-.issue-detail__header {
780-  margin-bottom: 20px;
781-  padding-bottom: 15px;
782-  border-bottom: 1px solid var(--border);
783-}
784-
785-.issue-detail__header h1 {
786-  margin-bottom: 10px;
787-}
788-
789-.issue-detail__meta {
790-  display: flex;
791-  gap: 10px;
792-  align-items: center;
793-  margin-bottom: 10px;
794-}
795-
796-.issue-detail__labels {
797-  margin-top: 10px;
798-}
799-
800-.issue-description {
801-  margin-bottom: 30px;
802-}
803-
804-.issue-comment {
805-  padding: 15px;
806-  border: 1px solid var(--border);
807-  border-radius: 6px;
808-  margin-bottom: 15px;
809-}
810-
811-.issue-comment__header {
812-  display: flex;
813-  justify-content: space-between;
814-  margin-bottom: 10px;
815-  padding-bottom: 10px;
816-  border-bottom: 1px solid var(--border);
817-}
818-
819-.issue-comment__author {
820-  font-weight: bold;
821-}
822-
823-.issue-comment__date {
824-  opacity: 0.7;
825-  font-size: 0.9em;
826-}
827-
828-.issue-comment__body {
829-  line-height: 1.6;
830-}
831-
832-.issue-comments h3 {
833-  margin-bottom: 15px;
834-}
835-</style>
836-```
837-
838-**Step 2: Commit**
839-
840-```bash
841-jj commit -m "feat: add CSS styling for issues"
842-```
843-
844----
845-
846-## Task 11: Build and Test
847-
848-**Files:**
849-- Run: Build and test commands
850-
851-**Step 1: Build the project**
852-
853-```bash
854-go build -o pgit
855-```
856-
857-**Step 2: Test without --issues (should work as before)**
858-
859-```bash
860-./pgit --revs HEAD --out ./test-output
861-```
862-
863-**Step 3: Test with --issues on repo without git-bug (should skip gracefully)**
864-
865-```bash
866-./pgit --revs HEAD --out ./test-output --issues
867-```
868-
869-Expected: Should see "no git-bug issues found, skipping issue generation" in logs
870-
871-**Step 4: Test with --issues on repo with git-bug (if available)**
872-
873-If you have a repo with git-bug:
874-```bash
875-./pgit --repo /path/to/repo-with-issues --revs HEAD --out ./test-output --issues
876-```
877-
878-Expected: Should see issues pages generated
879-
880-**Step 5: Commit**
881-
882-```bash
883-jj commit -m "build: verify issues feature compiles and runs"
884-```
885-
886----
887-
888-## Task 12: Add Template Function for IssuesListURL
889-
890-**Files:**
891-- Modify: `main.go` (writeHtml method to add template functions)
892-
893-**Step 1: Add template function**
894-
895-In `writeHtml` method, modify the template parsing to include a function map:
896-
897-```go
898-func (c *Config) writeHtml(writeData *WriteData) {
899-	// Create function map
900-	funcMap := template.FuncMap{
901-		"getIssuesListURL": func(filter string, label string) template.URL {
902-			return c.getIssuesListURL(filter, label)
903-		},
904-	}
905-	
906-	ts, err := template.New("").Funcs(funcMap).ParseFS(
907-		embedFS,
908-		writeData.Template,
909-		"html/header.partial.tmpl",
910-		"html/footer.partial.tmpl",
911-		"html/base.layout.tmpl",
912-	)
913-	bail(err)
914-	
915-	// ... rest of method ...
916-}
917-```
918-
919-**Step 2: Commit**
920-
921-```bash
922-jj commit -m "feat: add getIssuesListURL template function"
923-```
924-
925----
926-
927-## Summary
928-
929-After completing all tasks, the pgit static site generator will:
930-
931-1. Support `--issues` flag to enable git-bug integration
932-2. Generate `/issues/open/index.html` with all open issues
933-3. Generate `/issues/closed/index.html` with all closed issues
934-4. Generate `/issues/label/{label}/index.html` for each label (URL-encoded)
935-5. Generate `/issues/{short-hash}.html` for each issue
936-6. Show issue counts and navigation links on list pages
937-7. Display individual issues with title, status, labels, description, and comments
938-8. Add "Issues" link to site navigation
939-
940-All pages will match the existing pgit styling and use the same template system.
D docs/plans/2026-04-08-client-side-time-humanization.md
+0, -533
  1@@ -1,533 +0,0 @@
  2-# Client-Side Time Humanization Implementation Plan
  3-
  4-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
  5-
  6-**Goal:** Replace server-side time humanization with client-side JavaScript to ensure relative times remain accurate as pages age.
  7-
  8-**Architecture:** Add ISO 8601 timestamp fields to data structures, update templates to include `data-time` attributes with default MMM dd fallback text, and add inline JavaScript to the base layout that queries for these elements and updates their text content dynamically with relative times (minutes/hours/days ago).
  9-
 10-**Tech Stack:** Go templates, vanilla JavaScript (ES5-compatible for broad browser support), ISO 8601 timestamps.
 11-
 12----
 13-
 14-## Prerequisites
 15-
 16-- The codebase is a Go static site generator using embedded templates
 17-- Templates are in `html/*.tmpl` files
 18-- Current time humanization uses `humanize.Time()` from Go
 19-- Output is static HTML files
 20-
 21----
 22-
 23-## Task 1: Add ISO Timestamp Fields to Data Structures
 24-
 25-**Files:**
 26-- Modify: `main.go:121-131` (CommitData struct)
 27-- Modify: `main.go:133-149` (TreeItem struct)
 28-- Modify: `issues.go:14-27` (IssueData struct)
 29-- Modify: `issues.go:29-35` (CommentData struct)
 30-
 31-**Step 1: Add WhenISO field to CommitData struct**
 32-
 33-Add field after `WhenStr`:
 34-
 35-```go
 36-type CommitData struct {
 37-	SummaryStr   string
 38-	URL          template.URL
 39-	WhenStr      string
 40-	WhenISO      string  // ISO 8601 format for JavaScript parsing
 41-	HumanWhenStr string
 42-	AuthorStr    string
 43-	ShortID      string
 44-	ParentID     string
 45-	Refs         []*RefInfo
 46-	*git.Commit
 47-}
 48-```
 49-
 50-**Step 2: Add WhenISO field to TreeItem struct**
 51-
 52-Add field after `When`:
 53-
 54-```go
 55-type TreeItem struct {
 56-	IsTextFile bool
 57-	IsDir      bool
 58-	Size       string
 59-	NumLines   int
 60-	Name       string
 61-	Icon       string
 62-	Path       string
 63-	URL        template.URL
 64-	CommitID   string
 65-	CommitURL  template.URL
 66-	Summary    string
 67-	When       string
 68-	WhenISO    string  // ISO 8601 format for JavaScript parsing
 69-	Author     *git.Signature
 70-	Entry      *git.TreeEntry
 71-	Crumbs     []*Breadcrumb
 72-}
 73-```
 74-
 75-**Step 3: Add CreatedAtISO field to IssueData struct**
 76-
 77-```go
 78-type IssueData struct {
 79-	ID           string
 80-	FullID       string
 81-	Title        string
 82-	Status       string
 83-	Author       string
 84-	CreatedAt    string
 85-	CreatedAtISO string  // ISO 8601 format for JavaScript parsing
 86-	HumanDate    string
 87-	Labels       []string
 88-	CommentCount int
 89-	Description  template.HTML
 90-	Comments     []CommentData
 91-	URL          template.URL
 92-}
 93-```
 94-
 95-**Step 4: Add CreatedAtISO field to CommentData struct**
 96-
 97-```go
 98-type CommentData struct {
 99-	Author       string
100-	CreatedAt    string
101-	CreatedAtISO string  // ISO 8601 format for JavaScript parsing
102-	HumanDate    string
103-	Body         template.HTML
104-}
105-```
106-
107-**Step 5: Commit**
108-
109-```bash
110-jj commit -m "feat: add ISO timestamp fields to data structures"
111-```
112-
113----
114-
115-## Task 2: Populate ISO Timestamp Fields in CommitData
116-
117-**Files:**
118-- Modify: `main.go:1157-1168`
119-
120-**Step 1: Update commit data population**
121-
122-Find the `CommitData` initialization around line 1157 and add `WhenISO`:
123-
124-```go
125-cd := &CommitData{
126-	ParentID:     parentID,
127-	URL:          c.getCommitURL(commit.ID.String()),
128-	ShortID:      getShortID(commit.ID.String()),
129-	SummaryStr:   commit.Summary(),
130-	AuthorStr:    commit.Author.Name,
131-	WhenStr:      commit.Author.When.Format(time.DateOnly),
132-	WhenISO:      commit.Author.When.Format(time.RFC3339),
133-	HumanWhenStr: humanizeTime(commit.Author.When),
134-	Commit:       commit,
135-	Refs:         tags,
136-}
137-```
138-
139-**Step 2: Commit**
140-
141-```bash
142-jj commit -m "feat: populate WhenISO field for commits"
143-```
144-
145----
146-
147-## Task 3: Populate ISO Timestamp Fields in TreeItem
148-
149-**Files:**
150-- Modify: `main.go:1001-1055` (NewTreeItem function)
151-
152-**Step 1: Update TreeItem population**
153-
154-Find where `item.When` is set (around line 1032) and add `WhenISO`:
155-
156-```go
157-if len(lastCommits) > 0 {
158-	lc := lastCommits[0]
159-	item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
160-	item.CommitID = getShortID(lc.ID.String())
161-	item.Summary = lc.Summary()
162-	item.When = lc.Author.When.Format(time.DateOnly)
163-	item.WhenISO = lc.Author.When.Format(time.RFC3339)
164-	item.Author = lc.Author
165-}
166-```
167-
168-**Step 2: Commit**
169-
170-```bash
171-jj commit -m "feat: populate WhenISO field for tree items"
172-```
173-
174----
175-
176-## Task 4: Populate ISO Timestamp Fields in IssueData and CommentData
177-
178-**Files:**
179-- Modify: `issues.go:95-130`
180-
181-**Step 1: Update CommentData population**
182-
183-Find where `comments` slice is built and add `CreatedAtISO`:
184-
185-```go
186-comments = append(comments, CommentData{
187-	Author:       comment.Author.Name(),
188-	CreatedAt:    comment.FormatTime(),
189-	CreatedAtISO: comment.FormatTimeRFC3339(),
190-	HumanDate:    comment.FormatTimeRel(),
191-	Body:         c.renderMarkdown(comment.Message),
192-})
193-```
194-
195-**Note:** `FormatTimeRFC3339()` may not exist on the git-bug comment type. Check if available, otherwise use:
196-```go
197-CreatedAtISO: comment.UnixTime.Format(time.RFC3339),
198-```
199-
200-Or check what methods are available on the comment object.
201-
202-**Step 2: Update IssueData population**
203-
204-Find where `IssueData` is created (around line 116-130) and add `CreatedAtISO`:
205-
206-```go
207-issue := &IssueData{
208-	ID:           getShortID(fullID),
209-	FullID:       fullID,
210-	Title:        snap.Title,
211-	Status:       snap.Status.String(),
212-	Author:       snap.Author.Name(),
213-	CreatedAt:    snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
214-	CreatedAtISO: snap.CreateTime.Format(time.RFC3339),
215-	HumanDate:    humanizeTime(snap.CreateTime),
216-	Labels:       labels,
217-	CommentCount: commentCount,
218-	Description:  description,
219-	Comments:     comments,
220-	URL:          c.getIssueURL(fullID),
221-}
222-```
223-
224-**Step 3: Commit**
225-
226-```bash
227-jj commit -m "feat: populate ISO timestamp fields for issues and comments"
228-```
229-
230----
231-
232-## Task 5: Update Header Partial Template
233-
234-**Files:**
235-- Modify: `html/header.partial.tmpl:34-36`
236-
237-**Step 1: Update last commit bar time display**
238-
239-Replace:
240-```html
241-<div class="last-commit-bar__time" title="{{.LastCommit.WhenStr}}">
242-  {{.LastCommit.HumanWhenStr}}
243-</div>
244-```
245-
246-With:
247-```html
248-<div class="last-commit-bar__time" title="{{.LastCommit.WhenStr}}">
249-  <span class="human-time" data-time="{{.LastCommit.WhenISO}}">{{.LastCommit.WhenStr}}</span>
250-</div>
251-```
252-
253-**Step 2: Commit**
254-
255-```bash
256-jj commit -m "feat: add data-time attribute to header timestamp"
257-```
258-
259----
260-
261-## Task 6: Update Tree Page Template
262-
263-**Files:**
264-- Modify: `html/tree.page.tmpl:38`
265-
266-**Step 1: Update file listing timestamp**
267-
268-Replace:
269-```html
270-<a href="{{.CommitURL}}" title="{{.Summary}}">{{.When}}</a>
271-```
272-
273-With:
274-```html
275-<a href="{{.CommitURL}}" title="{{.Summary}}">
276-  <span class="human-time" data-time="{{.WhenISO}}">{{.When}}</span>
277-</a>
278-```
279-
280-**Step 2: Commit**
281-
282-```bash
283-jj commit -m "feat: add data-time attribute to tree view timestamps"
284-```
285-
286----
287-
288-## Task 7: Update Issues List Template
289-
290-**Files:**
291-- Modify: `html/issues_list.page.tmpl:52`
292-
293-**Step 1: Update issue list timestamp**
294-
295-Replace:
296-```html
297-<span class="issue-date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
298-```
299-
300-With:
301-```html
302-<span class="issue-date" title="{{.CreatedAt}}">
303-  <span class="human-time" data-time="{{.CreatedAtISO}}">{{.CreatedAt}}</span>
304-</span>
305-```
306-
307-**Step 2: Commit**
308-
309-```bash
310-jj commit -m "feat: add data-time attribute to issues list timestamps"
311-```
312-
313----
314-
315-## Task 8: Update Issue Detail Template
316-
317-**Files:**
318-- Modify: `html/issue_detail.page.tmpl:16,30,43`
319-
320-**Step 1: Update issue header timestamp (line 16)**
321-
322-Replace:
323-```html
324-<span class="issue-date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
325-```
326-
327-With:
328-```html
329-<span class="issue-date" title="{{.Issue.CreatedAt}}">
330-  <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAt}}</span>
331-</span>
332-```
333-
334-**Step 2: Update issue description timestamp (line 30)**
335-
336-Replace:
337-```html
338-<span class="issue-comment__date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
339-```
340-
341-With:
342-```html
343-<span class="issue-comment__date" title="{{.Issue.CreatedAt}}">
344-  <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAt}}</span>
345-</span>
346-```
347-
348-**Step 3: Update comment timestamp (line 43)**
349-
350-Replace:
351-```html
352-<span class="issue-comment__date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
353-```
354-
355-With:
356-```html
357-<span class="issue-comment__date" title="{{.CreatedAt}}">
358-  <span class="human-time" data-time="{{.CreatedAtISO}}">{{.CreatedAt}}</span>
359-</span>
360-```
361-
362-**Step 4: Commit**
363-
364-```bash
365-jj commit -m "feat: add data-time attributes to issue detail timestamps"
366-```
367-
368----
369-
370-## Task 9: Add JavaScript to Base Layout
371-
372-**Files:**
373-- Modify: `html/base.layout.tmpl:15-21`
374-
375-**Step 1: Add inline JavaScript before closing body tag**
376-
377-Add this script before the closing `</body>` tag:
378-
379-```html
380-<script>
381-(function() {
382-  var MINUTE_MS = 60000;
383-  var HOUR_MS = 3600000;
384-  var DAY_MS = 86400000;
385-  var MONTH_MS = 30 * DAY_MS;
386-  
387-  function updateTimes() {
388-    var elements = document.querySelectorAll('[data-time]');
389-    var now = new Date();
390-    var minDiffMs = Infinity;
391-    
392-    elements.forEach(function(el) {
393-      var date = new Date(el.getAttribute('data-time'));
394-      var diffMs = now - date;
395-      
396-      // Track the smallest difference for interval calculation
397-      if (diffMs < minDiffMs && diffMs >= 0) {
398-        minDiffMs = diffMs;
399-      }
400-      
401-      var diffMins = Math.floor(diffMs / MINUTE_MS);
402-      var diffHours = Math.floor(diffMs / HOUR_MS);
403-      var diffDays = Math.floor(diffMs / DAY_MS);
404-      
405-      var text;
406-      if (diffMins < 1) {
407-        text = 'just now';
408-      } else if (diffMins < 60) {
409-        text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
410-      } else if (diffHours < 24) {
411-        text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
412-      } else if (diffDays < 30) {
413-        text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
414-      } else {
415-        // Keep default MMM dd format (already in element text)
416-        return;
417-      }
418-      el.textContent = text;
419-    });
420-    
421-    return minDiffMs;
422-  }
423-  
424-  function scheduleUpdate() {
425-    var minDiffMs = updateTimes();
426-    var intervalMs;
427-    
428-    // Determine interval based on smallest time difference
429-    if (minDiffMs < HOUR_MS) {
430-      // Smallest diff is in minutes - update every minute
431-      intervalMs = MINUTE_MS;
432-    } else if (minDiffMs < DAY_MS) {
433-      // Smallest diff is in hours - update every hour
434-      intervalMs = HOUR_MS;
435-    } else if (minDiffMs < MONTH_MS) {
436-      // Smallest diff is in days - update every day
437-      intervalMs = DAY_MS;
438-    } else {
439-      // All timestamps are > 30 days, no updates needed
440-      return;
441-    }
442-    
443-    setTimeout(scheduleUpdate, intervalMs);
444-  }
445-  
446-  scheduleUpdate();
447-})();
448-</script>
449-```
450-
451-**Rationale:** This approach dynamically adjusts the update interval based on the granularity needed:
452-- If the most recent timestamp is < 1 hour old → update every minute (to track "X minutes ago")
453-- If the most recent timestamp is 1-24 hours old → update every hour (to track "X hours ago")  
454-- If the most recent timestamp is 1-30 days old → update every day (to track "X days ago")
455-- If all timestamps are > 30 days → no interval needed (static dates don't change)
456-
457-**Step 2: Commit**
458-
459-```bash
460-jj commit -m "feat: add client-side time humanization JavaScript"
461-```
462-
463----
464-
465-## Task 10: Build and Test
466-
467-**Files:**
468-- Run: Build commands
469-- Verify: Output HTML in testdata.site/
470-
471-**Step 1: Build the project**
472-
473-```bash
474-cd /home/btburke/projects/pgit
475-go build -o pgit .
476-```
477-
478-Expected: Successful build with no errors.
479-
480-**Step 2: Regenerate test site**
481-
482-```bash
483-./pgit --repo ./testdata --out ./testdata.site --revs main,branch-c --home-url https://test.com/test --clone-url https://test.com/test/test2 --issues
484-```
485-
486-Expected: Site generates successfully.
487-
488-**Step 3: Verify HTML output**
489-
490-Check `testdata.site/index.html`:
491-- Should contain `<span class="human-time" data-time="2026-04-...">`
492-- Should contain the inline JavaScript
493-
494-Check `testdata.site/issues/open/index.html`:
495-- Should contain `data-time` attributes on issue timestamps
496-
497-Check `testdata.site/logs/main/index.html`:
498-- Should NOT have data-time (log page uses WhenStr without humanization currently - this is acceptable)
499-
500-**Step 4: Manual browser test**
501-
502-Open `testdata.site/index.html` in a browser:
503-- The timestamp should show relative time (e.g., "2 days ago") if the commit is recent
504-- Or it should show the default date format ("2026-04-07") if older than 30 days
505-- View page source to verify `data-time` attributes are present
506-
507-**Step 5: Commit**
508-
509-```bash
510-jj commit -m "chore: regenerate test site with client-side time humanization"
511-```
512-
513----
514-
515-## Summary
516-
517-This plan implements client-side time humanization by:
518-
519-1. Adding ISO 8601 timestamp fields to all data structures that display times
520-2. Populating those fields using `time.RFC3339` format
521-3. Wrapping time display elements with `<span class="human-time" data-time="...">`
522-4. Adding vanilla JavaScript to the base layout that:
523-   - Queries all `[data-time]` elements
524-   - Parses the ISO timestamp
525-   - Calculates relative time (minutes/hours/days ago)
526-   - Updates text content dynamically
527-   - Dynamically schedules updates based on the smallest time difference on the page
528-   - Uses minute/hour/day intervals as appropriate to minimize unnecessary work
529-
530-**Edge cases handled:**
531-- Times < 1 minute show "just now"
532-- Times > 30 days keep the default MMM dd format
533-- No-JS users see the default date format (graceful degradation)
534-- Uses ES5-compatible syntax for broad browser support
D docs/plans/2026-04-08-css-build-pipeline-design.md
+0, -129
  1@@ -1,129 +0,0 @@
  2-# CSS Build Pipeline Improvement - Design Document
  3-
  4-**Date:** 2026-04-08
  5-
  6-## Goal
  7-
  8-Improve the CSS build pipeline by concatenating all CSS files in the proper precedence order, minimizing the result, appending a content-based hash to the filename, and inserting the correct link in the header.
  9-
 10-## Architecture
 11-
 12-Build-time CSS processing integrated into the existing Go build flow. CSS files are concatenated in precedence order, minified using a Go library, hashed for cache-busting, and the resulting filename is passed to templates.
 13-
 14-**Tech Stack:**
 15-- Go 1.21+
 16-- `github.com/tdewolff/minify/v2` - CSS minification library
 17-- SHA256 for content hashing
 18-
 19----
 20-
 21-## Current State
 22-
 23-**Current CSS Setup:**
 24-- `static/pgit.css` - Main stylesheet (1380 lines, ~23KB)
 25-- `vars.css` - Dynamically generated from Chroma theme colors
 26-- `syntax.css` - Dynamically generated for syntax highlighting
 27-- CSS links are hardcoded in `html/base.layout.tmpl`
 28-
 29-**Current Build Flow:**
 30-1. `copyStatic()` copies static files as-is
 31-2. `vars.css` and `syntax.css` are generated in main.go
 32-3. No minification or concatenation happens
 33-
 34----
 35-
 36-## Design Decisions
 37-
 38-### CSS Precedence Order
 39-
 40-1. **`pgit.css`** - Base styles (default variables, layout, components)
 41-2. **`vars.css`** - Theme overrides (CSS variables from Chroma theme)  
 42-3. **`syntax.css`** - Syntax highlighting (`.chroma*` selectors)
 43-
 44-This order ensures base styles load first, theme variables override defaults, and syntax highlighting has its specific selectors applied last.
 45-
 46-### CSS Minification Library
 47-
 48-**Selected:** `github.com/tdewolff/minify/v2`
 49-
 50-**Rationale:**
 51-- Pure Go implementation (no external dependencies)
 52-- Very fast (~50-70MB/s for CSS processing)
 53-- Safe minifications (removes comments/whitespace, optimizes colors/numbers, but won't restructure rules)
 54-- Well maintained (4.1k stars, active development)
 55-- Simple API for integration
 56-- Can also handle HTML minification if needed in future
 57-
 58-**Typical compression:** 10-15% reduction for CSS files.
 59-
 60-### Build Flow
 61-
 62-**New steps in `main()`:**
 63-
 64-1. **Collect CSS content in order:**
 65-   - Read `static/pgit.css` from embedded FS
 66-   - Generate `vars.css` content (from existing `style()` function)
 67-   - Generate `syntax.css` content (from existing `formatter.WriteCSS()`)
 68-
 69-2. **Concatenate:** `pgit.css` + `vars.css` + `syntax.css`
 70-
 71-3. **Minify** using `tdewolff/minify/v2/css`
 72-
 73-4. **Generate hash** (SHA256, first 8 chars of hex encoding)
 74-
 75-5. **Write file:** `styles.{hash}.css`
 76-
 77-6. **Update Config** with the CSS filename for templates
 78-
 79-7. **Update template** `html/base.layout.tmpl` to use single CSS link
 80-
 81-### Output Structure
 82-
 83-```
 84-public/
 85-  styles.a3f7b2c1.css   # Minified, hashed bundle (replaces pgit.css, vars.css, syntax.css)
 86-  index.html            # References styles.a3f7b2c1.css
 87-  ...
 88-```
 89-
 90----
 91-
 92-## Files to Modify
 93-
 94-1. **`main.go`** - Add CSS bundling logic
 95-   - Add `CSSFile` field to `Config` struct
 96-   - Create `bundleCSS()` function
 97-   - Call it in `main()` after theme setup, before `writeRepo()`
 98-
 99-2. **`html/base.layout.tmpl`** - Update CSS link
100-   - Replace two `<link>` tags with single `{{.Repo.CSSFile}}` reference
101-
102-3. **`go.mod`** - Add tdewolff/minify dependency
103-
104----
105-
106-## Out of Scope
107-
108-- HTML minification (pages are already lightweight)
109-- Source maps for CSS (not needed)
110-- External CSS processing tools (keeping it pure Go)
111-- Additional CSS files (none planned)
112-
113----
114-
115-## Success Criteria
116-
117-- [ ] All three CSS files concatenated in correct order
118-- [ ] Result is minified (smaller than sum of individual files)
119-- [ ] Filename includes content-based hash (e.g., `styles.a3f7b2c1.css`)
120-- [ ] HTML templates reference the hashed CSS file correctly
121-- [ ] Site renders identically to before (visual parity)
122-- [ ] Build completes without errors
123-
124-## Benefits
125-
126-- ✅ **Single HTTP request** for all CSS (vs. 3 separate files)
127-- ✅ **Cache-busting** via content hash in filename
128-- ✅ **Smaller file size** (~10-15% reduction from minification)
129-- ✅ **No external tools** - pure Go implementation
130-- ✅ **Deterministic** - same content = same hash
D docs/plans/2026-04-08-css-build-pipeline-implementation.md
+0, -402
  1@@ -1,402 +0,0 @@
  2-# CSS Build Pipeline Implementation Plan
  3-
  4-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
  5-
  6-**Goal:** Concatenate all CSS files in proper precedence order, minimize, add content hash to filename, and update templates to reference the bundled CSS.
  7-
  8-**Architecture:** Build-time CSS processing integrated into existing Go build flow using tdewolff/minify library.
  9-
 10-**Tech Stack:** Go 1.21+, github.com/tdewolff/minify/v2, SHA256 hashing
 11-
 12----
 13-
 14-## Task 1: Add CSS minification dependency
 15-
 16-**Files:**
 17-- Modify: `go.mod`
 18-
 19-**Step 1: Add tdewolff/minify dependency**
 20-
 21-Run: `go get github.com/tdewolff/minify/v2/css`
 22-
 23-Expected: Module added to go.mod and go.sum updated
 24-
 25-**Step 2: Commit**
 26-
 27-```bash
 28-jj commit -m "deps: add tdewolff/minify for CSS processing"
 29-```
 30-
 31----
 32-
 33-## Task 2: Add CSSFile field to Config struct
 34-
 35-**Files:**
 36-- Modify: `main.go:39-81` (Config struct)
 37-
 38-**Step 1: Add CSSFile field**
 39-
 40-Add to Config struct after line 80:
 41-
 42-```go
 43-	// CSS bundle filename (with content hash)
 44-	CSSFile string
 45-```
 46-
 47-**Step 2: Verify struct compiles**
 48-
 49-Run: `go build -o /dev/null .`
 50-
 51-Expected: Build succeeds
 52-
 53-**Step 3: Commit**
 54-
 55-```bash
 56-jj commit -m "feat: add CSSFile field to Config struct"
 57-```
 58-
 59----
 60-
 61-## Task 3: Create CSS bundling function
 62-
 63-**Files:**
 64-- Modify: `main.go` (add new function before main())
 65-
 66-**Step 1: Add imports for minification and hashing**
 67-
 68-Add to imports section:
 69-
 70-```go
 71-	"crypto/sha256"
 72-	"encoding/hex"
 73-
 74-	"github.com/tdewolff/minify/v2"
 75-	"github.com/tdewolff/minify/v2/css"
 76-```
 77-
 78-**Step 2: Create bundleCSS function**
 79-
 80-Add before `main()` function:
 81-
 82-```go
 83-// bundleCSS concatenates, minifies, and hashes all CSS files
 84-// Returns the filename of the bundled CSS (e.g., "styles.a3f7b2c1.css")
 85-func (c *Config) bundleCSS() (string, error) {
 86-	c.Logger.Info("bundling CSS files")
 87-
 88-	// Initialize minifier
 89-	m := minify.New()
 90-	m.AddFunc("text/css", css.Minify)
 91-
 92-	var buf bytes.Buffer
 93-
 94-	// 1. Read pgit.css from embedded static FS
 95-	pgitCSS, err := staticFS.ReadFile("static/pgit.css")
 96-	if err != nil {
 97-		return "", fmt.Errorf("failed to read pgit.css: %w", err)
 98-	}
 99-	buf.Write(pgitCSS)
100-	buf.WriteString("\n")
101-
102-	// 2. Generate vars.css content
103-	varsCSS := style(*c.Theme)
104-	buf.WriteString(varsCSS)
105-	buf.WriteString("\n")
106-
107-	// 3. Generate syntax.css content
108-	var syntaxBuf bytes.Buffer
109-	err = c.Formatter.WriteCSS(&syntaxBuf, c.Theme)
110-	if err != nil {
111-		return "", fmt.Errorf("failed to generate syntax.css: %w", err)
112-	}
113-	buf.Write(syntaxBuf.Bytes())
114-
115-	// 4. Minify the concatenated CSS
116-	minified, err := m.Bytes("text/css", buf.Bytes())
117-	if err != nil {
118-		return "", fmt.Errorf("failed to minify CSS: %w", err)
119-	}
120-
121-	// 5. Generate content hash (first 8 chars of SHA256)
122-	hash := sha256.Sum256(minified)
123-	hashStr := hex.EncodeToString(hash[:8])
124-
125-	// 6. Create filename with hash
126-	filename := fmt.Sprintf("styles.%s.css", hashStr)
127-
128-	// 7. Write to output directory
129-	outPath := filepath.Join(c.Outdir, filename)
130-	err = os.WriteFile(outPath, minified, 0644)
131-	if err != nil {
132-		return "", fmt.Errorf("failed to write CSS bundle: %w", err)
133-	}
134-
135-	c.Logger.Info("CSS bundle created", "filename", filename, "size", len(minified))
136-
137-	return filename, nil
138-}
139-```
140-
141-**Step 3: Verify function compiles**
142-
143-Run: `go build -o /dev/null .`
144-
145-Expected: Build succeeds
146-
147-**Step 4: Commit**
148-
149-```bash
150-jj commit -m "feat: add CSS bundling function"
151-```
152-
153----
154-
155-## Task 4: Integrate CSS bundling into main build flow
156-
157-**Files:**
158-- Modify: `main.go:1349-1367` (main function end)
159-
160-**Step 1: Call bundleCSS before writeRepo**
161-
162-Locate this section in main():
163-```go
164-	config.writeRepo()
165-	err = config.copyStatic("static")
166-	bail(err)
167-```
168-
169-Replace with:
170-```go
171-	// Bundle CSS files before generating site
172-	cssFile, err := config.bundleCSS()
173-	bail(err)
174-	config.CSSFile = cssFile
175-
176-	config.writeRepo()
177-	
178-	// Note: copyStatic is no longer needed for CSS files since they're bundled
179-	// but we keep it for any other static assets
180-	err = config.copyStatic("static")
181-	bail(err)
182-```
183-
184-**Step 2: Remove old CSS generation code**
185-
186-Remove this section at the end of main():
187-```go
188-	styles := style(*theme)
189-	err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
190-	if err != nil {
191-		panic(err)
192-	}
193-
194-	fp := filepath.Join(out, "syntax.css")
195-	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
196-	if err != nil {
197-		bail(err)
198-	}
199-	err = formatter.WriteCSS(w, theme)
200-	if err != nil {
201-		bail(err)
202-	}
203-```
204-
205-**Step 3: Verify build succeeds**
206-
207-Run: `go build -o pgit .`
208-
209-Expected: Build succeeds
210-
211-**Step 4: Commit**
212-
213-```bash
214-jj commit -m "feat: integrate CSS bundling into build flow"
215-```
216-
217----
218-
219-## Task 5: Update base layout template
220-
221-**Files:**
222-- Modify: `html/base.layout.tmpl:13-15`
223-
224-**Step 1: Replace CSS link tags**
225-
226-Current:
227-```html
228-    <link rel="stylesheet" href="{{.Repo.RootRelative}}pgit.css" />
229-    <link rel="stylesheet" href="{{.Repo.RootRelative}}vars.css" />
230-```
231-
232-Replace with:
233-```html
234-    <link rel="stylesheet" href="{{.Repo.RootRelative}}{{.Repo.CSSFile}}" />
235-```
236-
237-**Step 2: Verify template syntax**
238-
239-No build step for templates - will be validated at runtime
240-
241-**Step 3: Commit**
242-
243-```bash
244-jj commit -m "feat: update template to use bundled CSS"
245-```
246-
247----
248-
249-## Task 6: Test the implementation
250-
251-**Files:**
252-- Test: Run build and verify output
253-
254-**Step 1: Build the binary**
255-
256-Run: `go build -o pgit .`
257-
258-Expected: Clean build
259-
260-**Step 2: Run test-site target**
261-
262-Run: `make test-site`
263-
264-Expected: Build completes successfully
265-
266-**Step 3: Verify CSS bundle was created**
267-
268-Run: `ls -la testdata.site/*.css`
269-
270-Expected: Single file `styles.{hash}.css` exists (no pgit.css, vars.css, syntax.css)
271-
272-**Step 4: Verify file content**
273-
274-Run: `head -5 testdata.site/styles.*.css`
275-
276-Expected: Minified CSS (no whitespace, all on few lines)
277-
278-**Step 5: Verify HTML references correct file**
279-
280-Run: `grep -o 'styles\.[a-f0-9]*\.css' testdata.site/index.html`
281-
282-Expected: Matches the filename from step 3
283-
284-**Step 6: Check file size reduction**
285-
286-Run: `wc -c static/pgit.css` and compare with bundled file
287-
288-Expected: Bundled file should be smaller than original pgit.css alone (due to minification)
289-
290-**Step 7: Commit**
291-
292-```bash
293-jj commit -m "test: verify CSS bundling works correctly"
294-```
295-
296----
297-
298-## Task 7: Verify visual parity
299-
300-**Files:**
301-- Test: Compare rendered output
302-
303-**Step 1: Start a local server**
304-
305-Run: `cd testdata.site && python3 -m http.server 8080 &`
306-
307-**Step 2: Check a page renders correctly**
308-
309-Open browser or use curl: `curl -s http://localhost:8080/index.html | head -20`
310-
311-Expected: HTML loads, CSS is referenced correctly
312-
313-**Step 3: Verify CSS loads**
314-
315-Run: `curl -s http://localhost:8080/styles.*.css | head -5`
316-
317-Expected: Returns minified CSS content
318-
319-**Step 4: Stop test server**
320-
321-Run: `pkill -f "http.server 8080"`
322-
323-**Step 5: Commit**
324-
325-```bash
326-jj commit -m "test: verify visual parity and CSS loading"
327-```
328-
329----
330-
331-## Task 8: Clean up (optional)
332-
333-**Files:**
334-- Modify: `main.go` (remove unused code if desired)
335-
336-**Step 1: Review if copyStatic is still needed**
337-
338-If `static/` only contained CSS files, we can remove the `copyStatic` call entirely.
339-
340-Check: `ls static/`
341-
342-If only CSS files, remove:
343-```go
344-	err = config.copyStatic("static")
345-	bail(err)
346-```
347-
348-**Step 2: Commit if changes made**
349-
350-```bash
351-jj commit -m "cleanup: remove unused copyStatic call"
352-```
353-
354----
355-
356-## Task 9: Final verification
357-
358-**Files:**
359-- Test: Full build pipeline
360-
361-**Step 1: Clean and rebuild**
362-
363-Run:
364-```bash
365-make clean
366-make build
367-make test-site
368-```
369-
370-Expected: All steps succeed
371-
372-**Step 2: Verify final output**
373-
374-Run: `ls -la testdata.site/ | grep -E '\.(css|html)$'`
375-
376-Expected:
377-- One CSS file: `styles.{hash}.css`
378-- Multiple HTML files
379-- No `pgit.css`, `vars.css`, or `syntax.css`
380-
381-**Step 3: Final commit**
382-
383-```bash
384-jj commit -m "feat: complete CSS build pipeline improvement"
385-```
386-
387----
388-
389-## Summary
390-
391-After completing all tasks:
392-
393-1. ✅ All three CSS files concatenated in correct order (pgit.css → vars.css → syntax.css)
394-2. ✅ Result is minified using tdewolff/minify
395-3. ✅ Filename includes 8-char content hash (e.g., `styles.a3f7b2c1.css`)
396-4. ✅ HTML templates reference the hashed CSS file via `{{.Repo.CSSFile}}`
397-5. ✅ Site renders identically to before
398-6. ✅ Build completes without errors
399-
400-**Files changed:**
401-- `go.mod` - Added tdewolff/minify dependency
402-- `main.go` - Added bundleCSS function and integrated into build flow
403-- `html/base.layout.tmpl` - Updated to use bundled CSS
D docs/plans/2026-04-11-commit-msg-hook-for-git-bug.md
+0, -1151
   1@@ -1,1151 +0,0 @@
   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-```
D docs/plans/2026-04-11-commit-msg-hook-simple.md
+0, -592
  1@@ -1,592 +0,0 @@
  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
D docs/plans/2026-04-11-commit-trailers-design.md
+0, -59
 1@@ -1,59 +0,0 @@
 2-# Design: Display Git Trailers on Commit Detail Page
 3-
 4-## Overview
 5-Add support for displaying git commit trailers (like `Signed-off-by:`, `Co-authored-by:`, etc.) in the metadata section of the commit detail page.
 6-
 7-## Current State
 8-- Commit detail page uses `html/commit.page.tmpl` with a `.metadata` CSS grid
 9-- Currently displays: commit, parent, author, date
10-- `CommitData` struct embeds `*git.Commit` from `gogs/git-module`
11-
12-## Design Decisions
13-
14-### Approach: Manual Parsing (Option 1)
15-- **Why**: Simple, no dependencies, covers 99% of use cases
16-- Parse trailer lines matching `^([A-Za-z0-9-]+): (.+)$` at end of commit message
17-- Store as slice to preserve order and handle duplicates
18-
19-### Data Structure
20-```go
21-type Trailer struct {
22-    Key   string
23-    Value string
24-}
25-```
26-
27-Add to `CommitData`:
28-```go
29-type CommitData struct {
30-    // ... existing fields ...
31-    Trailers []Trailer
32-}
33-```
34-
35-### Template Changes
36-- Add trailer entries at bottom of `.metadata` div in `commit.page.tmpl`
37-- Match existing styling: `<div class="metadata__label">key</div>` + `<div class="metadata__value">value</div>`
38-
39-### Parsing Algorithm
40-1. Split commit message by newlines
41-2. Traverse from end, collecting lines matching trailer pattern
42-3. Stop at first non-trailer line or blank line (separator)
43-4. Preserve order as encountered in message
44-
45-### Edge Cases
46-- No trailers: metadata section unchanged
47-- Duplicate keys: show all values (preserving order)
48-- Empty value: skip (invalid trailer)
49-- Case sensitivity: preserve original case from commit
50-
51-## Files Modified
52-1. `main.go` - Add `Trailer` struct, parsing logic, populate in `CommitData`
53-2. `html/commit.page.tmpl` - Add template loop for trailers
54-
55-## Testing
56-- Verified with commits containing various trailer formats
57-- Handles edge cases gracefully
58-
59-## Approved By
60-User approved on 2026-04-11
D docs/plans/2026-04-11-commit-trailers-implementation.md
+0, -223
  1@@ -1,223 +0,0 @@
  2-# Commit Trailers Implementation Plan
  3-
  4-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
  5-
  6-**Goal:** Display git commit trailers (Signed-off-by, Co-authored-by, etc.) in the commit detail page metadata section.
  7-
  8-**Architecture:** Parse trailer lines from commit messages using regex pattern matching, store as structured data in CommitData, render in template following existing metadata styling.
  9-
 10-**Tech Stack:** Go, HTML templates (no external dependencies)
 11-
 12----
 13-
 14-### Task 1: Add Trailer struct and field to CommitData
 15-
 16-**Files:**
 17-- Modify: `/home/btburke/projects/pgit/main.go:123-134`
 18-
 19-**Step 1: Add Trailer struct definition**
 20-
 21-Add after line 122 (before CommitData struct):
 22-
 23-```go
 24-type Trailer struct {
 25-	Key   string
 26-	Value string
 27-}
 28-```
 29-
 30-**Step 2: Add Trailers field to CommitData struct**
 31-
 32-Modify lines 123-134, add `Trailers []Trailer` field:
 33-
 34-```go
 35-type CommitData struct {
 36-	SummaryStr  string
 37-	URL         template.URL
 38-	WhenStr     string
 39-	WhenISO     string
 40-	WhenDisplay string
 41-	AuthorStr   string
 42-	ShortID     string
 43-	ParentID    string
 44-	Refs        []*RefInfo
 45-	Trailers    []Trailer
 46-	*git.Commit
 47-}
 48-```
 49-
 50-**Step 3: Commit**
 51-
 52-```bash
 53-jj commit -m "feat: add Trailer struct and Trailers field to CommitData"
 54-```
 55-
 56----
 57-
 58-### Task 2: Implement trailer parsing function
 59-
 60-**Files:**
 61-- Modify: `/home/btburke/projects/pgit/main.go` (add new function)
 62-
 63-**Step 1: Add parseTrailers function**
 64-
 65-Add this function after the existing helper functions (around line 750, after getCommitURL):
 66-
 67-```go
 68-// parseTrailers extracts git trailer lines from a commit message.
 69-// Trailers are lines at the end of the message in "Key: value" format.
 70-// Returns trailers in the order they appear in the message.
 71-func parseTrailers(message string) []Trailer {
 72-	var trailers []Trailer
 73-	
 74-	// Trailer pattern: key with alphanumeric/hyphens, colon, space, value
 75-	// Examples: "Signed-off-by: John Doe", "Co-authored-by: Jane Smith"
 76-	trailerRe := regexp.MustCompile(`^([A-Za-z0-9-]+): (.+)$`)
 77-	
 78-	lines := strings.Split(message, "\n")
 79-	
 80-	// Collect trailer lines from the end of the message
 81-	for i := len(lines) - 1; i >= 0; i-- {
 82-		line := strings.TrimSpace(lines[i])
 83-		
 84-		// Stop at empty line (separator between message body and trailers)
 85-		if line == "" {
 86-			break
 87-		}
 88-		
 89-		matches := trailerRe.FindStringSubmatch(line)
 90-		if matches != nil {
 91-			trailers = append([]Trailer{
 92-				{Key: matches[1], Value: matches[2]},
 93-			}, trailers...)
 94-		} else {
 95-			// Not a trailer line, stop collecting
 96-			break
 97-		}
 98-	}
 99-	
100-	return trailers
101-}
102-```
103-
104-**Step 2: Import regexp package if not already imported**
105-
106-Check imports around line 1-20, add `"regexp"` if missing.
107-
108-**Step 3: Commit**
109-
110-```bash
111-jj commit -m "feat: add parseTrailers function to extract git trailers from messages"
112-```
113-
114----
115-
116-### Task 3: Populate Trailers field when creating CommitData
117-
118-**Files:**
119-- Modify: `/home/btburke/projects/pgit/main.go` (find where CommitData is created)
120-
121-**Step 1: Find CommitData creation points**
122-
123-Search for `&CommitData{` in main.go to find all places where CommitData is instantiated.
124-
125-**Step 2: Add Trailers field population**
126-
127-At each CommitData creation point, add the Trailers field by parsing the commit message.
128-
129-Example modification (when creating CommitData):
130-
131-```go
132-commitData := &CommitData{
133-    // ... existing fields ...
134-    Commit:   commit,
135-    Trailers: parseTrailers(commit.Message),
136-}
137-```
138-
139-**Step 3: Commit**
140-
141-```bash
142-jj commit -m "feat: populate Trailers field in CommitData instances"
143-```
144-
145----
146-
147-### Task 4: Update commit detail template to display trailers
148-
149-**Files:**
150-- Modify: `/home/btburke/projects/pgit/html/commit.page.tmpl:9-21`
151-
152-**Step 1: Add trailer loop to metadata section**
153-
154-Modify the metadata div to include trailer entries at the end:
155-
156-```html
157-    <div class="metadata">
158-      <div class="metadata__label">commit</div>
159-      <div class="metadata__value metadata__value--code"><a href="{{.CommitURL}}">{{.CommitID}}</a></div>
160-
161-      <div class="metadata__label">parent</div>
162-      <div class="metadata__value metadata__value--code"><a href="{{.ParentURL}}">{{.Parent}}</a></div>
163-
164-      <div class="metadata__label">author</div>
165-      <div class="metadata__value font-bold">{{.Commit.Author.Name}}</div>
166-
167-      <div class="metadata__label">date</div>
168-      <div class="metadata__value">{{.Commit.WhenISO}}</div>
169-
170-      {{range .Commit.Trailers}}
171-      <div class="metadata__label">{{.Key}}</div>
172-      <div class="metadata__value">{{.Value}}</div>
173-      {{end}}
174-    </div>
175-```
176-
177-**Step 2: Commit**
178-
179-```bash
180-jj commit -m "feat: display commit trailers in commit detail page metadata"
181-```
182-
183----
184-
185-### Task 5: Build and test
186-
187-**Files:**
188-- Test: Build and verify with test repository
189-
190-**Step 1: Build the project**
191-
192-```bash
193-cd /home/btburke/projects/pgit && go build -o pgit
194-```
195-
196-Expected: Clean build, no errors
197-
198-**Step 2: Test with testdata.repo**
199-
200-```bash
201-cd /home/btburke/projects/pgit && ./pgit -r ./testdata.repo -o ./test-output
202-```
203-
204-**Step 3: Verify trailer display**
205-
206-Check generated commit pages in `./test-output/commits/` directory. Look for commits with trailers (Signed-off-by, Co-authored-by, etc.) and verify they appear in the metadata section.
207-
208-**Step 4: Commit**
209-
210-```bash
211-jj commit -m "chore: verify commit trailers feature works correctly"
212-```
213-
214----
215-
216-## Verification Checklist
217-
218-- [ ] Trailer struct added to main.go
219-- [ ] Trailers field added to CommitData
220-- [ ] parseTrailers function implemented
221-- [ ] Trailers populated when creating CommitData
222-- [ ] Template updated to display trailers
223-- [ ] Project builds successfully
224-- [ ] Test output shows trailers in metadata section
M html/file.page.tmpl
+12, -12
 1@@ -11,20 +11,20 @@
 2 
 3   {{if .Repo.HideTreeLastCommit}}
 4   {{else}}
 5-  <div class="box file-header">
 6-    <div class="file-header__row">
 7-      <div class="file-header__commit">
 8-        <a href="{{.Item.CommitURL}}">{{.Item.Summary}}</a>
 9+  <div class="file-header">
10+    <div class="commit-item__header">
11+      <div class="commit-item__content">
12+        <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="{{.Item.CommitURL}}">{{.Item.Summary}}</a></div>
13+        <div class="commit-item__meta">
14+          <span class="font-bold">{{.Item.Author.Name}}</span>
15+          <span>&nbsp;&centerdot;&nbsp;</span>
16+          <span>{{.Item.When}}</span>
17+        </div>
18       </div>
19-      <div class="file-header__hash">
20-        <a href="{{.Item.CommitURL}}">{{.Item.CommitID}}</a>
21-      </div>
22-    </div>
23 
24-    <div class="file-header__author">
25-      <span>{{.Item.Author.Name}}</span>
26-      <span>&nbsp;&centerdot;&nbsp;</span>
27-      <span>{{.Item.When}}</span>
28+      <div class="commit-item__actions">
29+        <a href="{{.Item.CommitURL}}" class="commit-item__hash">{{.Item.CommitID}}</a>
30+      </div>
31     </div>
32   </div>
33   {{end}}
M static/pgit.css
+30, -30
  1@@ -310,13 +310,23 @@ sup {
  2 }
  3 
  4 @keyframes fade-out {
  5-  from { opacity: 1; }
  6-  to { opacity: 0; }
  7+  from {
  8+    opacity: 1;
  9+  }
 10+
 11+  to {
 12+    opacity: 0;
 13+  }
 14 }
 15 
 16 @keyframes fade-in {
 17-  from { opacity: 0; }
 18-  to { opacity: 1; }
 19+  from {
 20+    opacity: 0;
 21+  }
 22+
 23+  to {
 24+    opacity: 1;
 25+  }
 26 }
 27 
 28 /* Prevent flash during HTMX swaps */
 29@@ -329,8 +339,13 @@ sup {
 30 }
 31 
 32 @keyframes htmx-fade-in {
 33-  from { opacity: 0.8; }
 34-  to { opacity: 1; }
 35+  from {
 36+    opacity: 0.8;
 37+  }
 38+
 39+  to {
 40+    opacity: 1;
 41+  }
 42 }
 43 
 44 /* Site Header */
 45@@ -522,6 +537,9 @@ sup {
 46   margin: 0 -0.5rem;
 47 }
 48 
 49+.commit-item:hover {
 50+  background: var(--pre);
 51+}
 52 
 53 .commit-item__header {
 54   display: flex;
 55@@ -535,6 +553,7 @@ sup {
 56   margin-right: 1rem;
 57 }
 58 
 59+
 60 .commit-item__message {
 61   margin: 0;
 62   display: inline-block;
 63@@ -702,33 +721,13 @@ sup {
 64   font-weight: bold;
 65 }
 66 
 67-/* File Header Box */
 68+/* File Header - uses commit-item classes for consistent styling with log page */
 69 .file-header {
 70   margin: 1rem 0;
 71-  padding: var(--grid-height);
 72-  border: 1px solid var(--border);
 73-  border-radius: 4px;
 74-  font-family: monospace;
 75-}
 76-
 77-.file-header__row {
 78-  display: flex;
 79-  align-items: center;
 80-  justify-content: space-between;
 81-}
 82-
 83-.file-header__commit {
 84-  flex: 1;
 85-}
 86-
 87-.file-header__hash {
 88-  font-family: monospace;
 89 }
 90 
 91-.file-header__author {
 92-  display: flex;
 93-  align-items: center;
 94-  margin-top: var(--grid-height);
 95+.file-header .commit-item__header {
 96+  margin-bottom: 0;
 97 }
 98 
 99 /* Box Component */
100@@ -1136,6 +1135,7 @@ sup {
101 
102 .issues-header {
103   border-bottom: 1px solid var(--border);
104+  margin-bottom: 0.5rem;
105 }
106 
107 .issues-header h1 {
108@@ -1252,7 +1252,6 @@ sup {
109   justify-content: space-between;
110   align-items: flex-start;
111   padding: 0.25rem 0.5rem;
112-  border-bottom: 1px solid var(--grey);
113   transition: background-color 0.2s ease;
114   margin: 0 -0.5rem;
115 }
116@@ -1270,6 +1269,7 @@ sup {
117 }
118 
119 .issue-item__title {
120+  color: var(--text-color) !important;
121   font-size: 1rem;
122   font-weight: bold;
123   text-decoration: none;