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 | // 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 | |||
| 3 | import ( | 3 | import ( |
| 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. |
| 16 | func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string { | 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 | 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. |
| 28 | func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string { | 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 | 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. | ||
| 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 | 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 @@ | |||
| 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/ | ||