Commit 9c0a74553a
Verified · cmc
Layout: unified · split
internal/control/read.go +51 −26
| @@ -108,24 +108,34 @@ func runRepoRefs(c *Ctx, args []string) int { | ||
| 108 | 108 | // Tags read newest version first; the JSON keeps git's order. |
| 109 | 109 | sorted := slices.Clone(tags) |
| 110 | 110 | gitutil.SortVersions(sorted) |
| 111 | return c.emit(d, func(w io.Writer) { | |
| 111 | return c.emitView(d, func(w io.Writer) { | |
| 112 | 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 | 113 | for _, ref := range d.Branches { |
| 121 | if c.Term.Cols == 0 || ref.Name != repo.DefaultBranch { | |
| 122 | tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 123 | } | |
| 114 | tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 124 | 115 | } |
| 125 | 116 | for _, ref := range sorted { |
| 126 | 117 | tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) |
| 127 | 118 | } |
| 128 | 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 | 334 | d.Entries = append(d.Entries, eo) |
| 325 | 335 | } |
| 326 | return c.emit(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 | } | |
| 336 | return c.emitView(d, func(w io.Writer) { | |
| 341 | 337 | tb := c.table(w, "SHA", "SIZE", "NAME") |
| 342 | 338 | for _, e := range d.Entries { |
| 343 | 339 | name := e.Name |
| @@ -351,6 +347,35 @@ func runRepoTree(c *Ctx, args []string) int { | ||
| 351 | 347 | tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name)) |
| 352 | 348 | } |
| 353 | 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 | 226 | ds = append(ds, d) |
| 227 | 227 | } |
| 228 | return c.emit(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 | } | |
| 228 | return c.emitView(ds, func(w io.Writer) { | |
| 238 | 229 | tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR") |
| 239 | 230 | for _, d := range ds { |
| 240 | 231 | tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject), |
| 241 | 232 | cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail))) |
| 242 | 233 | } |
| 243 | 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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "crypto/ed25519" |
| 6 | "os" | |
| 7 | "os/exec" | |
| 8 | "path/filepath" | |
| 6 | 9 | "strings" |
| 7 | 10 | "testing" |
| 8 | 11 | |
| @@ -14,9 +17,16 @@ import ( | ||
| 14 | 17 | // dispatchAs runs argv as u with stdin, piped, and returns stdout; a |
| 15 | 18 | // non-zero exit fails t. |
| 16 | 19 | func 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. | |
| 25 | func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string, argv ...string) string { | |
| 17 | 26 | t.Helper() |
| 18 | 27 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} |
| 19 | 28 | c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)} |
| 29 | c.Cfg.Server.Root = root | |
| 20 | 30 | if code := Dispatch(c, argv); code != protocol.ExitOK { |
| 21 | 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 | 36 | // atTerminal runs argv as u at a 100-column terminal without colour |
| 27 | 37 | // and returns stdout; a non-zero exit fails t. |
| 28 | 38 | func 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. | |
| 44 | func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv ...string) string { | |
| 29 | 45 | t.Helper() |
| 30 | 46 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} |
| 31 | 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 | 49 | if code := Dispatch(c, argv); code != protocol.ExitOK { |
| 33 | 50 | t.Fatalf("%v: exit %d: %s", argv, code, errOut) |
| 34 | 51 | } |
| @@ -147,3 +164,83 @@ func TestRepoListScreens(t *testing.T) { | ||
| 147 | 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. | |
| 171 | func 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 | ||
| 203 | func 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 | ||
| 219 | func 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 | 115 | for _, r := range rows { |
| 116 | 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 | 119 | tb := c.table(w, "NAME", "KIND", "LOCATION") |
| 120 | 120 | for _, d := range ds { |
| 121 | 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 | 127 | if idx.State == "partial" { |
| 128 | 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 @@ | ||
| 1 | aee4475ad9 unsigned docs: guide (t <t@example.test>) | |
internal/control/testdata/plain/repo-log.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | aee4475ad9 unsigned docs: guide (t <t@example.test>) | |
| 2 | 75e07603f5 unsigned first (t <t@example.test>) | |
internal/control/testdata/plain/repo-refs.txt added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | branch feature aee4475ad9 | |
| 2 | branch main aee4475ad9 | |
| 3 | tag v1.0.0 aee4475ad9 | |
internal/control/testdata/plain/repo-symbols.txt added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | Parse function p.go:1 | |
| 2 | ParseAll function p.go:2 | |
| 3 | ParseArgs function p.go:3 | |
| 4 | Parser function p.go:4 | |
| 5 | Parser.Run method p.go:40 | |
| 6 | parse function p.go:5 | |
internal/control/testdata/plain/repo-tree-docs.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | cd51204800 8 guide.md | |
internal/control/testdata/plain/repo-tree.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | e2bd1550b1 6 README.md | |
| 2 | 604e844ed3 - docs/ | |