package control import ( "bytes" "encoding/base64" "fmt" "io" "path" "strconv" "strings" "time" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" ) func init() { register(Command{ Path: []string{"repo", "tree"}, Summary: "list a directory", Usage: "repo tree [] [--ref ]", Flags: []Flag{ {"--ref", "", "branch, tag or commit to read", "the default branch"}, }, Examples: []string{"repo tree krz/gitbay internal/control"}, ReadOnly: true, Run: runRepoTree, }) register(Command{ Path: []string{"repo", "cat"}, Summary: "read a file", Usage: "repo cat [--ref ]", Flags: []Flag{ {"--ref", "", "branch, tag or commit to read", "the default branch"}, }, Examples: []string{"repo cat krz/gitbay internal/control/control.go"}, ReadOnly: true, Run: runRepoCat, }) register(Command{ Path: []string{"repo", "blame"}, Summary: "attribute lines to commits", Usage: "repo blame [--ref ] [--from ] [--to ]", Flags: []Flag{ {"--ref", "", "branch, tag or commit to read", "the default branch"}, {"--from", "", "first line to attribute", "1"}, {"--to", "", "last line to attribute", "the file's last line"}, }, Examples: []string{"repo blame krz/gitbay internal/control/control.go --from 1 --to 40"}, ReadOnly: true, Run: runRepoBlame, }) register(Command{ Path: []string{"repo", "readme"}, Summary: "print a repository's README", Usage: "repo readme [--ref ]", Flags: []Flag{ {"--ref", "", "branch, tag or commit to read", "the default branch"}, }, Examples: []string{"repo readme krz/gitbay"}, ReadOnly: true, Run: runRepoReadme, }) register(Command{ Path: []string{"repo", "refs"}, Summary: "list branches and tags", Usage: "repo refs ", Examples: []string{"repo refs krz/gitbay"}, ReadOnly: true, Run: runRepoRefs, }) } func runRepoRefs(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) branches, err := gitutil.Refs(dir, "heads") if err != nil { return c.fail(protocol.ExitFailure, "listing branches: %v", err) } tags, err := gitutil.Refs(dir, "tags") if err != nil { return c.fail(protocol.ExitFailure, "listing tags: %v", err) } type refOut struct { Name string `json:"name"` SHA string `json:"sha"` } type out struct { Branches []refOut `json:"branches"` Tags []refOut `json:"tags"` } d := out{Branches: []refOut{}, Tags: []refOut{}} for _, ref := range branches { d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA}) } for _, ref := range tags { d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA}) } return c.emit(d, func(w io.Writer) { tb := c.table(w, "KIND", "NAME", "SHA") for _, ref := range d.Branches { tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) } for _, ref := range d.Tags { tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) } tb.flush() }) } // BlameSpan caps one blame request, and is the page size the web renders. // An unbounded blame on a large file is a slow query for every surface. const BlameSpan = 1000 func runRepoBlame(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } rest, ref := f.Pos, f.Value("--ref") from, to := 0, 0 for name, dst := range map[string]*int{"--from": &from, "--to": &to} { if !f.Has(name) { continue } n, err := strconv.Atoi(f.Value(name)) if err != nil || n < 1 { return c.fail(protocol.ExitUsage, "%s must be a positive line number", name) } *dst = n } if len(rest) != 2 { return c.usage() } repo, code := resolveRepo(c, rest[0], policy.CanRead) if code >= 0 { return code } filePath, ok := cleanRepoPath(rest[1]) if !ok || filePath == "" { return c.fail(protocol.ExitUsage, "path must stay inside the repository") } if ref == "" { ref = repo.DefaultBranch } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, ref); err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) } data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes) if err != nil { return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref) } if gitutil.IsBinary(data) { return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath) } total := bytes.Count(data, []byte("\n")) if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) { total++ } if total == 0 { return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref) } if from == 0 { from = 1 } if from > total { return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total) } if to == 0 || to > total { to = total } if to < from { return c.fail(protocol.ExitUsage, "--to must not precede --from") } // One span per call; a client pages with --from/--to. if to-from+1 > BlameSpan { to = from + BlameSpan - 1 } raw, err := gitutil.Blame(dir, ref, filePath, from, to) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type hunkOut struct { SHA string `json:"sha"` AuthorName string `json:"author_name"` AuthorEmail string `json:"author_email"` Date string `json:"date"` Summary string `json:"summary"` StartLine int `json:"start_line"` Lines []string `json:"lines"` } type out struct { Path string `json:"path"` Ref string `json:"ref"` File string `json:"file"` From int `json:"from"` To int `json:"to"` TotalLines int `json:"total_lines"` Hunks []hunkOut `json:"hunks"` } d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to, TotalLines: total, Hunks: []hunkOut{}} for _, h := range raw { d.Hunks = append(d.Hunks, hunkOut{ SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail, Date: time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339), Summary: h.Summary, StartLine: h.StartLine, Lines: h.Lines, }) } return c.emit(d, func(w io.Writer) { for _, h := range d.Hunks { for i, line := range h.Lines { fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line) } } }) } // readArgs pulls the shared " [positional...] [--ref r]" shape // off argv. Positionals are returned in order so each command can name them // in its own usage message. func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) { f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage}) if err != nil { return nil, "", c.fail(protocol.ExitUsage, "%v", err) } pos, ref = f.Pos, f.Value("--ref") return pos, ref, -1 } // cleanRepoPath keeps a caller inside the repository: no absolute paths, no // "..", no leading slash. git would resolve those against the work tree. func cleanRepoPath(p string) (string, bool) { p = strings.Trim(p, "/") if p == "" { return "", true } cleaned := path.Clean(p) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") { return "", false } return cleaned, true } // entryOut is one tree entry. The sha lets a client cache by object id // rather than by path and ref, which is what makes an offline client // tractable. type entryOut struct { Name string `json:"name"` Type string `json:"type"` // blob | tree Mode string `json:"mode"` SHA string `json:"sha"` Size int64 `json:"size,omitempty"` // absent for trees } func runRepoTree(c *Ctx, args []string) int { pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2) if code >= 0 { return code } if len(pos) == 0 { return c.usage() } repo, code := resolveRepo(c, pos[0], policy.CanRead) if code >= 0 { return code } dirPath := "" if len(pos) == 2 { var ok bool if dirPath, ok = cleanRepoPath(pos[1]); !ok { return c.fail(protocol.ExitUsage, "path must stay inside the repository") } } if ref == "" { ref = repo.DefaultBranch } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, ref); err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) } entries, err := gitutil.ListTree(dir, ref, dirPath) if err != nil { return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref) } type out struct { Path string `json:"path"` Ref string `json:"ref"` Dir string `json:"dir"` Entries []entryOut `json:"entries"` } d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}} for _, e := range entries { eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA} if e.Type != "tree" && e.Size >= 0 { eo.Size = e.Size } d.Entries = append(d.Entries, eo) } return c.emit(d, func(w io.Writer) { tb := c.table(w, "SHA", "SIZE", "NAME") for _, e := range d.Entries { name := e.Name if e.Type == "tree" { name += "/" } tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), cText(sizeCol(e)), cFlex(name)) } tb.flush() }) } func runRepoReadme(c *Ctx, args []string) int { pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1) if code >= 0 { return code } if len(pos) != 1 { return c.usage() } repo, code := resolveRepo(c, pos[0], policy.CanRead) if code >= 0 { return code } if ref == "" { ref = repo.DefaultBranch } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, ref); err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) } entries, err := gitutil.ListTree(dir, ref, "") if err != nil { return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref) } name := PickReadme(entries) if name == "" { return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref) } limit := c.Cfg.Limits.MaxBlobBytes data, err := gitutil.ReadBlob(dir, ref, name, limit+1) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } truncated := int64(len(data)) > limit if truncated { data = data[:limit] } binary := gitutil.IsBinary(data) type out struct { Path string `json:"path"` Ref string `json:"ref"` File string `json:"file"` Size int `json:"size"` Binary bool `json:"binary"` Truncated bool `json:"truncated,omitempty"` Content string `json:"content,omitempty"` Base64 string `json:"base64,omitempty"` } d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data), Binary: binary, Truncated: truncated} if binary { d.Base64 = base64.StdEncoding.EncodeToString(data) } else { d.Content = string(data) } return c.emit(d, func(w io.Writer) { if binary { fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", name, len(data)) return } w.Write(data) if len(data) > 0 && data[len(data)-1] != '\n' { fmt.Fprintln(w) } }) } func sizeCol(e entryOut) string { if e.Type == "tree" { return "-" } return fmt.Sprintf("%d", e.Size) } // readmeRank orders competing README files: richer renderers win. var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} // PickReadme returns the best README-ish blob in a tree listing: any // file named "readme" or "readme." (case-insensitive), preferring // formats we can render richly. func PickReadme(entries []gitutil.TreeEntry) string { best, bestRank := "", 1<<30 for _, e := range entries { if e.Type != "blob" { continue } lower := strings.ToLower(e.Name) if lower != "readme" && !strings.HasPrefix(lower, "readme.") { continue } rank, ok := readmeRank[path.Ext(lower)] if !ok { rank = 10 // plaintext fallback } if rank < bestRank { best, bestRank = e.Name, rank } } return best } func runRepoCat(c *Ctx, args []string) int { pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2) if code >= 0 { return code } if len(pos) != 2 { return c.usage() } repo, code := resolveRepo(c, pos[0], policy.CanRead) if code >= 0 { return code } filePath, ok := cleanRepoPath(pos[1]) if !ok || filePath == "" { return c.fail(protocol.ExitUsage, "path must stay inside the repository") } if ref == "" { ref = repo.DefaultBranch } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, ref); err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) } // One byte over the cap distinguishes "exactly at the limit" from // "truncated", so a client is told which it got. limit := c.Cfg.Limits.MaxBlobBytes data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1) if err != nil { return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref) } truncated := int64(len(data)) > limit if truncated { data = data[:limit] } binary := gitutil.IsBinary(data) type out struct { Path string `json:"path"` Ref string `json:"ref"` File string `json:"file"` Size int `json:"size"` Binary bool `json:"binary"` Truncated bool `json:"truncated,omitempty"` // Exactly one of these is set: text for UTF-8-safe content, // base64 for anything else, so a client never has to guess. Content string `json:"content,omitempty"` Base64 string `json:"base64,omitempty"` } d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data), Binary: binary, Truncated: truncated} if binary { d.Base64 = base64.StdEncoding.EncodeToString(data) } else { d.Content = string(data) } return c.emit(d, func(w io.Writer) { if binary { fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data)) return } w.Write(data) if len(data) > 0 && data[len(data)-1] != '\n' { fmt.Fprintln(w) } }) }