Commit 9c0a74553a

9c0a74553ab842b363928d3c0c35a390146ef06c

parent: b46c9682ce

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:13 UTC

control: repo log, tree, refs and symbols as screens

Ref #319

Layout: unified · split

internal/control/read.go +51 −26
@@ -108,24 +108,34 @@ func runRepoRefs(c *Ctx, args []string) int {
108 // Tags read newest version first; the JSON keeps git's order. 108 // Tags read newest version first; the JSON keeps git's order.
109 sorted := slices.Clone(tags) 109 sorted := slices.Clone(tags)
110 gitutil.SortVersions(sorted) 110 gitutil.SortVersions(sorted)
111 return c.emit(d, func(w io.Writer) { 111 return c.emitView(d, func(w io.Writer) {
112 tb := c.table(w, "KIND", "NAME", "SHA") 112 tb := c.table(w, "KIND", "NAME", "SHA")
113 if c.Term.Cols > 0 {
114 for _, ref := range d.Branches {
115 if ref.Name == repo.DefaultBranch {
116 tb.row(cText("default"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
117 }
118 }
119 }
120 for _, ref := range d.Branches { 113 for _, ref := range d.Branches {
121 if c.Term.Cols == 0 || ref.Name != repo.DefaultBranch { 114 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
122 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
123 }
124 } 115 }
125 for _, ref := range sorted { 116 for _, ref := range sorted {
126 tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) 117 tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
127 } 118 }
128 tb.flush() 119 tb.flush()
120 }, func() screen {
121 branches := section{title: "Branches", n: len(d.Branches)}
122 for _, r := range d.Branches {
123 if r.Name == repo.DefaultBranch {
124 branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA), "default")))
125 }
126 }
127 for _, r := range d.Branches {
128 if r.Name != repo.DefaultBranch {
129 branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
130 }
131 }
132 tags := section{title: "Tags", n: len(sorted)}
133 for _, r := range sorted {
134 tags.rows = append(tags.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
135 }
136 return screen{sections: []section{branches, tags}, actions: []action{
137 {"Read", []string{"repo", "log", repo.Path(), "--ref", repo.DefaultBranch}},
138 }}
129 }) 139 })
130} 140}
131 141
@@ -323,21 +333,7 @@ func runRepoTree(c *Ctx, args []string) int {
323 } 333 }
324 d.Entries = append(d.Entries, eo) 334 d.Entries = append(d.Entries, eo)
325 } 335 }
326 return c.emit(d, func(w io.Writer) { 336 return c.emitView(d, func(w io.Writer) {
327 if c.Term.Cols > 0 {
328 tb := c.table(w, "NAME", "SIZE", "SHA")
329 for _, e := range d.Entries {
330 name, size := cText(e.Name), cText("")
331 if e.Type == "tree" {
332 name = cMark(e.Name+"/", sgrCyan)
333 } else {
334 size = cSize(e.Size)
335 }
336 tb.row(name, size, cMark(e.SHA[:min(10, len(e.SHA))], sgrDim))
337 }
338 tb.flush()
339 return
340 }
341 tb := c.table(w, "SHA", "SIZE", "NAME") 337 tb := c.table(w, "SHA", "SIZE", "NAME")
342 for _, e := range d.Entries { 338 for _, e := range d.Entries {
343 name := e.Name 339 name := e.Name
@@ -351,6 +347,35 @@ func runRepoTree(c *Ctx, args []string) int {
351 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name)) 347 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name))
352 } 348 }
353 tb.flush() 349 tb.flush()
350 }, func() screen {
351 title := repo.Path()
352 if dirPath != "" {
353 title += "/" + dirPath
354 }
355 rows := make([]row, len(d.Entries))
356 firstFile := ""
357 for i, e := range d.Entries {
358 name, size := cText(e.Name), cText("")
359 if e.Type == "tree" {
360 name = cMark(e.Name+"/", sgrBlue)
361 } else {
362 size = cSize(e.Size)
363 if firstFile == "" {
364 firstFile = path.Join(dirPath, e.Name)
365 }
366 }
367 rows[i] = rowOf(name, size, cMeta(e.SHA[:min(10, len(e.SHA))]))
368 }
369 s := listScreen(title+" at "+ref, rows)
370 if firstFile != "" {
371 s.actions = append(s.actions, action{"Read", []string{"repo", "cat", repo.Path(), firstFile, "--ref", ref}})
372 }
373 logArgs := []string{"repo", "log", repo.Path(), "--ref", ref}
374 if dirPath != "" {
375 logArgs = append(logArgs, "--path", dirPath)
376 }
377 s.actions = append(s.actions, action{"Read", logArgs})
378 return s
354 }) 379 })
355} 380}
356 381
internal/control/sig.go +16 −10
@@ -225,22 +225,28 @@ func runRepoLog(c *Ctx, args []string) int {
225 } 225 }
226 ds = append(ds, d) 226 ds = append(ds, d)
227 } 227 }
228 return c.emit(ds, func(w io.Writer) { 228 return c.emitView(ds, func(w io.Writer) {
229 if c.Term.Cols > 0 {
230 tb := c.table(w, "SHA", "SUBJECT", "AUTHOR", "WHEN", "SIGNATURE")
231 for _, d := range ds {
232 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cFlex(d.Subject), cText(d.AuthorName),
233 cAge(d.Date), cState(d.Signature.State))
234 }
235 tb.flush()
236 return
237 }
238 tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR") 229 tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR")
239 for _, d := range ds { 230 for _, d := range ds {
240 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject), 231 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject),
241 cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail))) 232 cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail)))
242 } 233 }
243 tb.flush() 234 tb.flush()
235 }, func() screen {
236 title := "Commits on " + ref
237 if filePath != "" {
238 title += " touching " + filePath
239 }
240 rows := make([]row, len(ds))
241 for i, d := range ds {
242 rows[i] = rowOf(cRef(fmt.Sprintf("%.10s", d.SHA)), cGlyph(d.Signature.State), cFlex(d.Subject), cMeta(d.AuthorName, relAge(d.Date, termNow())))
243 }
244 s := listScreen(title, rows)
245 if len(ds) > 0 {
246 s.actions = append(s.actions, action{"Read", []string{"repo", "commit", repo.Path(), ds[0].SHA[:min(12, len(ds[0].SHA))]}})
247 }
248 s.actions = append(s.actions, action{"Read", []string{"repo", "tree", repo.Path(), "--ref", ref}})
249 return s
244 }) 250 })
245} 251}
246 252
internal/control/stage3repo_test.go +97
@@ -3,6 +3,9 @@ package control
3import ( 3import (
4 "bytes" 4 "bytes"
5 "crypto/ed25519" 5 "crypto/ed25519"
6 "os"
7 "os/exec"
8 "path/filepath"
6 "strings" 9 "strings"
7 "testing" 10 "testing"
8 11
@@ -14,9 +17,16 @@ import (
14// dispatchAs runs argv as u with stdin, piped, and returns stdout; a 17// dispatchAs runs argv as u with stdin, piped, and returns stdout; a
15// non-zero exit fails t. 18// non-zero exit fails t.
16func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string { 19func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string {
20 t.Helper()
21 return dispatchIn(t, st, u, "", stdin, argv...)
22}
23
24// dispatchIn is dispatchAs with repositories under root.
25func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string, argv ...string) string {
17 t.Helper() 26 t.Helper()
18 out, errOut := &bytes.Buffer{}, &bytes.Buffer{} 27 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
19 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)} 28 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)}
29 c.Cfg.Server.Root = root
20 if code := Dispatch(c, argv); code != protocol.ExitOK { 30 if code := Dispatch(c, argv); code != protocol.ExitOK {
21 t.Fatalf("%v: exit %d: %s", argv, code, errOut) 31 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
22 } 32 }
@@ -26,9 +36,16 @@ func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv
26// atTerminal runs argv as u at a 100-column terminal without colour 36// atTerminal runs argv as u at a 100-column terminal without colour
27// and returns stdout; a non-zero exit fails t. 37// and returns stdout; a non-zero exit fails t.
28func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string { 38func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string {
39 t.Helper()
40 return atTerminalIn(t, st, u, "", argv...)
41}
42
43// atTerminalIn is atTerminal with repositories under root.
44func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv ...string) string {
29 t.Helper() 45 t.Helper()
30 out, errOut := &bytes.Buffer{}, &bytes.Buffer{} 46 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
31 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(""), Term: Term{Cols: 100}} 47 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(""), Term: Term{Cols: 100}}
48 c.Cfg.Server.Root = root
32 if code := Dispatch(c, argv); code != protocol.ExitOK { 49 if code := Dispatch(c, argv); code != protocol.ExitOK {
33 t.Fatalf("%v: exit %d: %s", argv, code, errOut) 50 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
34 } 51 }
@@ -147,3 +164,83 @@ func TestRepoListScreens(t *testing.T) {
147 checkLegend(t, out) 164 checkLegend(t, out)
148 } 165 }
149} 166}
167
168// browseFixture is a repository on disk under root: two commits on main
169// (the second touching docs/), a branch, and a tag, with fixed dates so
170// the shas are the same on every run.
171func browseFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
172 t.Helper()
173 st, repo, uid := newQueueTestRepo(t)
174 root := t.TempDir()
175 env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z")
176 git := func(dir string, args ...string) {
177 t.Helper()
178 cmd := exec.Command("git", args...)
179 cmd.Dir, cmd.Env = dir, env
180 if out, err := cmd.CombinedOutput(); err != nil {
181 t.Fatalf("git %v: %v\n%s", args, err, out)
182 }
183 }
184 src := filepath.Join(root, "src")
185 if err := os.MkdirAll(filepath.Join(src, "docs"), 0o755); err != nil {
186 t.Fatal(err)
187 }
188 os.WriteFile(filepath.Join(src, "README.md"), []byte("# app\n"), 0o644)
189 git(root, "init", "-q", "-b", "main", "src")
190 git(src, "add", ".")
191 git(src, "commit", "-q", "-m", "first")
192 os.WriteFile(filepath.Join(src, "docs", "guide.md"), []byte("# guide\n"), 0o644)
193 git(src, "add", ".")
194 git(src, "commit", "-q", "-m", "docs: guide")
195 git(src, "tag", "v1.0.0")
196 git(src, "branch", "feature")
197 dir := RepoDir(root, repo.OwnerName, repo.Name)
198 os.MkdirAll(filepath.Dir(dir), 0o755)
199 git(root, "clone", "-q", "--bare", src, dir)
200 return st, repo, store.User{ID: uid, Username: "alice"}, root
201}
202
203func TestRepoBrowsePlainPinned(t *testing.T) {
204 st, repo, u, root := browseFixture(t)
205 p := repo.Path()
206 for name, argv := range map[string][]string{
207 "repo-log": {"repo", "log", p},
208 "repo-log-path": {"repo", "log", p, "--path", "docs/guide.md"},
209 "repo-tree": {"repo", "tree", p},
210 "repo-tree-docs": {"repo", "tree", p, "docs"},
211 "repo-refs": {"repo", "refs", p},
212 } {
213 pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...))
214 }
215 sst, srepo, alice, _ := symbolsFixture(t)
216 pinPlain(t, "repo-symbols", dispatchAs(t, sst, alice, "", "repo", "symbols", srepo.Path(), "Pars"))
217}
218
219func TestRepoBrowseScreens(t *testing.T) {
220 st, repo, u, root := browseFixture(t)
221 p := repo.Path()
222 for _, tc := range []struct {
223 argv []string
224 want []string
225 }{
226 {[]string{"repo", "log", p}, []string{"Commits on main (2)\n", " docs: guide t · "}},
227 {[]string{"repo", "log", p, "--path", "docs/guide.md"}, []string{"Commits on main touching docs/guide.md (1)\n"}},
228 {[]string{"repo", "tree", p}, []string{"alice/app at main (2)\n", "docs/", "README.md"}},
229 {[]string{"repo", "tree", p, "docs"}, []string{"alice/app/docs at main (1)\n", "guide.md"}},
230 {[]string{"repo", "refs", p}, []string{"Branches (2)\nmain", "default", "Tags (1)\nv1.0.0"}},
231 } {
232 out := atTerminalIn(t, st, u, root, tc.argv...)
233 for _, w := range tc.want {
234 if !strings.Contains(out, w) {
235 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
236 }
237 }
238 checkLegend(t, out)
239 }
240 sst, srepo, alice, _ := symbolsFixture(t)
241 out := atTerminal(t, sst, alice, "repo", "symbols", srepo.Path(), "Pars")
242 if !strings.Contains(out, "Symbols matching \"Pars\" (") || !strings.Contains(out, "Parse function p.go:1") {
243 t.Errorf("symbols:\n%s", out)
244 }
245 checkLegend(t, out)
246}
internal/control/symbols.go +21 −1
@@ -115,7 +115,7 @@ func runRepoSymbols(c *Ctx, args []string) int {
115 for _, r := range rows { 115 for _, r := range rows {
116 ds = append(ds, symbolOut{r.Name, r.Kind, r.Path, r.Line}) 116 ds = append(ds, symbolOut{r.Name, r.Kind, r.Path, r.Line})
117 } 117 }
118 return c.emitPage(p, ds, next, func(w io.Writer) { 118 return c.emitPageView(p, ds, next, func(w io.Writer) {
119 tb := c.table(w, "NAME", "KIND", "LOCATION") 119 tb := c.table(w, "NAME", "KIND", "LOCATION")
120 for _, d := range ds { 120 for _, d := range ds {
121 tb.row(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line))) 121 tb.row(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
@@ -127,6 +127,26 @@ func runRepoSymbols(c *Ctx, args []string) int {
127 if idx.State == "partial" { 127 if idx.State == "partial" {
128 fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note) 128 fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note)
129 } 129 }
130 }, func() screen {
131 rows := make([]row, len(ds))
132 for i, d := range ds {
133 rows[i] = rowOf(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
134 }
135 if capped {
136 fmt.Fprintf(c.Stderr, "first %d matches; page with --limit and --cursor\n", symbolsUnpaged)
137 }
138 if idx.State == "partial" {
139 fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note)
140 }
141 s := listScreen(fmt.Sprintf("Symbols matching %q", query), rows)
142 if len(ds) > 0 {
143 cat := []string{"repo", "cat", repo.Path(), ds[0].Path}
144 if ref != "" {
145 cat = append(cat, "--ref", ref)
146 }
147 s.actions = []action{{"Read", cat}}
148 }
149 return s
130 }) 150 })
131} 151}
132 152
internal/control/testdata/plain/repo-log-path.txt added +1
@@ -0,0 +1 @@
1aee4475ad9 unsigned docs: guide (t <t@example.test>)
internal/control/testdata/plain/repo-log.txt added +2
@@ -0,0 +1,2 @@
1aee4475ad9 unsigned docs: guide (t <t@example.test>)
275e07603f5 unsigned first (t <t@example.test>)
internal/control/testdata/plain/repo-refs.txt added +3
@@ -0,0 +1,3 @@
1branch feature aee4475ad9
2branch main aee4475ad9
3tag v1.0.0 aee4475ad9
internal/control/testdata/plain/repo-symbols.txt added +6
@@ -0,0 +1,6 @@
1Parse function p.go:1
2ParseAll function p.go:2
3ParseArgs function p.go:3
4Parser function p.go:4
5Parser.Run method p.go:40
6parse function p.go:5
internal/control/testdata/plain/repo-tree-docs.txt added +1
@@ -0,0 +1 @@
1cd51204800 8 guide.md
internal/control/testdata/plain/repo-tree.txt added +2
@@ -0,0 +1,2 @@
1e2bd1550b1 6 README.md
2604e844ed3 - docs/