11 files changed,
+43,
-4070
+1,
-0
1@@ -4,3 +4,4 @@ pgit
2 public/
3 testdata.site/
4 testdata.repo/
5+docs/plans/
+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.
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
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
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
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-```
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
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
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
+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> · </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> · </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}}
+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;