package modules import ( "fmt" "html/template" "strconv" "strings" "gsg/internal/core" "gsg/internal/gitx" ) // Graph is the branch/commit DAG laid out in lanes, one row per commit. // // Layout runs in a single pass over --topo-order output, which guarantees a // child is emitted before its parents. `active` holds, per lane, the SHA that // lane is currently waiting to draw. A commit claims the lane that was // expecting it (or a fresh one), hands that lane to its first parent, and // opens a new lane for each additional parent -- which is exactly what makes // a merge fork visually. type Graph struct { Rows []Row Lanes int } type Row struct { Commit gitx.Commit Lane int Refs []gitx.Ref // branch/tag tips pointing here // Through are lanes that pass this row untouched (draw a full-height // vertical). In are lanes merging into this commit (diagonal from the // top edge to the node). Out are lanes this commit forks into // (diagonal from the node to the bottom edge). Through []int In []int Out []int // Rail is the row's slice of the graph, drawn as SVG. Geometry lives // here because it IS the data; colour does not -- every stroke refers // to a CSS custom property, so the theme still owns the palette. Rail template.HTML } func init() { core.Register("graph", graphMod) } // graph: the DAG across every ref. {{ mod "graph" "120" }} func graphMod(c *core.Ctx, args ...string) (any, error) { n := 150 if len(args) > 0 { if v, err := strconv.Atoi(args[0]); err == nil && v > 0 { n = v } } commits, err := c.Repo.LogAll(n) if err != nil { return nil, err } tips := map[string][]gitx.Ref{} if refs, err := c.Repo.Refs(); err == nil { for _, r := range refs { tips[r.Target] = append(tips[r.Target], r) } } var g Graph var active []string // lane -> SHA that lane is waiting for ("" = free) for _, cm := range commits { row := Row{Commit: cm, Refs: tips[cm.SHA]} // Every lane already expecting this commit is an incoming edge. mine := -1 for i, want := range active { if want == cm.SHA { if mine == -1 { mine = i } else { row.In = append(row.In, i) active[i] = "" // that lane terminates here } } } if mine == -1 { // a tip: no child has claimed it mine = free(&active) } row.Lane = mine // Lanes untouched by this row pass straight through. for i, want := range active { if i != mine && want != "" && !contains(row.In, i) { row.Through = append(row.Through, i) } } if len(cm.Parents) == 0 { active[mine] = "" // root commit closes its lane } else { active[mine] = cm.Parents[0] for _, p := range cm.Parents[1:] { k := free(&active) active[k] = p row.Out = append(row.Out, k) } } if len(active) > g.Lanes { g.Lanes = len(active) } g.Rows = append(g.Rows, row) } for i := range g.Rows { g.Rows[i].Rail = rail(g.Rows[i], g.Lanes) } if g.Lanes == 0 { g.Lanes = 1 } return g, nil } // Row geometry, in px. Height must match the table row height in CSS. const ( laneW = 15.0 rowH = 30.0 radius = 3.6 ) func lane(i int) string { return fmt.Sprintf("var(--lane-%d)", i%6) } func x(i int) float64 { return float64(i)*laneW + laneW/2 } func rail(r Row, lanes int) template.HTML { var b strings.Builder w := float64(lanes) * laneW fmt.Fprintf(&b, ``) return template.HTML(b.String()) } func free(active *[]string) int { for i, v := range *active { if v == "" { return i } } *active = append(*active, "") return len(*active) - 1 } func contains(xs []int, n int) bool { for _, x := range xs { if x == n { return true } } return false }