control: repo blame, repo commit-file, repo refs; milestone on mr show !99
10 files changed, +521 −66
e2e/accounts_test.go +24
| @@ -155,6 +155,30 @@ func TestWebAccounts(t *testing.T) { | ||
| 155 | 155 | t.Fatalf("edited content not served: %d %q", status, body) |
| 156 | 156 | } |
| 157 | 157 | |
| 158 | // Editing is a command, so it works from the CLI too — the web is one | |
| 159 | // rendering of it. This is the capability that used to be web-only. | |
| 160 | if _, errOut, code := inst.ssh(t, aliceKey, "edited from ssh\n", | |
| 161 | "repo", "commit-file", "alice/site", "notes.txt", | |
| 162 | "--ref", "main", "--message", "'ssh edit'", "--file", "-"); code != 0 { | |
| 163 | t.Fatalf("repo commit-file: %s", errOut) | |
| 164 | } | |
| 165 | if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from ssh") { | |
| 166 | t.Fatalf("ssh edit not served: %d %q", status, body) | |
| 167 | } | |
| 168 | // A path cannot climb out of the repository. | |
| 169 | if _, _, code := inst.ssh(t, aliceKey, "x", "repo", "commit-file", "alice/site", | |
| 170 | "../../etc/passwd", "--ref", "main", "--file", "-"); code == 0 { | |
| 171 | t.Error("commit-file escaped the repository") | |
| 172 | } | |
| 173 | // A stranger with no write access cannot commit. | |
| 174 | strangerKey := inst.newKey(t, "mallory") | |
| 175 | inst.admin(t, "admin", "user", "create", "mallory", | |
| 176 | "--key", strangerKey+".pub", "--email", "mallory@example.test", "--verified") | |
| 177 | if _, _, code := inst.ssh(t, strangerKey, "x", "repo", "commit-file", "alice/site", | |
| 178 | "notes.txt", "--ref", "main", "--file", "-"); code == 0 { | |
| 179 | t.Error("a stranger committed to a repository they cannot write") | |
| 180 | } | |
| 181 | ||
| 158 | 182 | // A require-signed repo refuses web edits instead of violating itself. |
| 159 | 183 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 { |
| 160 | 184 | t.Fatal("require-signed failed") |
e2e/apiread_test.go +20 −1
| @@ -35,9 +35,25 @@ func TestRepoTreeAndCat(t *testing.T) { | ||
| 35 | 35 | mustGit(t, dir, env, "add", ".") |
| 36 | 36 | mustGit(t, dir, env, "commit", "-q", "-m", "base") |
| 37 | 37 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 38 | mustGit(t, dir, env, "checkout", "-q", "-b", "feature") | |
| 39 | mustGit(t, dir, env, "push", "-q", "origin", "feature") | |
| 40 | mustGit(t, dir, env, "tag", "v1.0.0") | |
| 41 | mustGit(t, dir, env, "push", "-q", "origin", "v1.0.0") | |
| 42 | ||
| 43 | // Refs are a control-plane read so native clients can present the same | |
| 44 | // branch and tag choices as the web without synthesizing them. | |
| 45 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "refs", "alice/app", "--json") | |
| 46 | if code != 0 { | |
| 47 | t.Fatalf("repo refs: %s", errOut) | |
| 48 | } | |
| 49 | if !strings.Contains(out, `"name":"main"`) || | |
| 50 | !strings.Contains(out, `"name":"feature"`) || | |
| 51 | !strings.Contains(out, `"name":"v1.0.0"`) { | |
| 52 | t.Fatalf("repo refs output: %s", out) | |
| 53 | } | |
| 38 | 54 | |
| 39 | 55 | // Tree at the root: directories and files, with sizes and object ids. |
| 40 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--json") | |
| 56 | out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--json") | |
| 41 | 57 | if code != 0 { |
| 42 | 58 | t.Fatalf("repo tree: %s", errOut) |
| 43 | 59 | } |
| @@ -134,6 +150,9 @@ func TestRepoTreeAndCat(t *testing.T) { | ||
| 134 | 150 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "cat", "alice/app", "README.md"); code == 0 { |
| 135 | 151 | t.Error("a stranger read a private repository's file") |
| 136 | 152 | } |
| 153 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "refs", "alice/app"); code == 0 { | |
| 154 | t.Error("a stranger listed a private repository's refs") | |
| 155 | } | |
| 137 | 156 | |
| 138 | 157 | // Paths cannot climb out of the repository. |
| 139 | 158 | for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "src/../../.."} { |
e2e/blame_test.go +50
| @@ -32,6 +32,56 @@ func TestBlameView(t *testing.T) { | ||
| 32 | 32 | mustGit(t, dir, env, "commit", "-q", "-m", "change line two") |
| 33 | 33 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 34 | 34 | |
| 35 | // Blame is a control command, so the CLI and the JSON API attribute | |
| 36 | // lines too — the web is one rendering of it, not the only one. | |
| 37 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", "f.txt", "--json") | |
| 38 | if code != 0 { | |
| 39 | t.Fatalf("repo blame: %s", errOut) | |
| 40 | } | |
| 41 | for _, want := range []string{ | |
| 42 | `"total_lines":3`, `"start_line":1`, | |
| 43 | `"summary":"first lines"`, `"summary":"change line two"`, | |
| 44 | `"TWO CHANGED"`, `"author_name":"t"`, // the fixture's git author | |
| 45 | } { | |
| 46 | if !strings.Contains(out, want) { | |
| 47 | t.Errorf("repo blame output missing %q: %s", want, out) | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | // A line range is a window on the same attribution. | |
| 52 | out, _, code = inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", "f.txt", | |
| 53 | "--from", "2", "--to", "2", "--json") | |
| 54 | if code != 0 || !strings.Contains(out, `"from":2`) || !strings.Contains(out, `"to":2`) { | |
| 55 | t.Fatalf("ranged blame: %s", out) | |
| 56 | } | |
| 57 | if strings.Contains(out, `"one"`) || strings.Contains(out, `"three"`) { | |
| 58 | t.Errorf("ranged blame leaked lines outside the window: %s", out) | |
| 59 | } | |
| 60 | ||
| 61 | // Binary files have nothing to attribute, and say so rather than 500. | |
| 62 | os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0, 1, 2, 0, 3}, 0o644) | |
| 63 | mustGit(t, dir, env, "add", ".") | |
| 64 | mustGit(t, dir, env, "commit", "-q", "-m", "binary") | |
| 65 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 66 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", "logo.bin"); code == 0 || | |
| 67 | !strings.Contains(errOut, "binary") { | |
| 68 | t.Errorf("binary blame: exit %d, %s", code, errOut) | |
| 69 | } | |
| 70 | ||
| 71 | // A stranger cannot attribute a private repository's lines. | |
| 72 | bobKey := inst.newKey(t, "bob") | |
| 73 | inst.admin(t, "admin", "user", "create", "bob", | |
| 74 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | |
| 75 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 { | |
| 76 | t.Fatal("visibility private") | |
| 77 | } | |
| 78 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "blame", "alice/app", "f.txt"); code == 0 { | |
| 79 | t.Error("a stranger blamed a private repository's file") | |
| 80 | } | |
| 81 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "public"); code != 0 { | |
| 82 | t.Fatal("visibility public") | |
| 83 | } | |
| 84 | ||
| 35 | 85 | status, body := inst.get(t, "/alice/app/blame/main/f.txt") |
| 36 | 86 | if status != 200 { |
| 37 | 87 | t.Fatalf("blame page: %d", status) |
e2e/milestone_test.go +4
| @@ -77,6 +77,10 @@ func TestMilestonesAndTemplates(t *testing.T) { | ||
| 77 | 77 | if !strings.Contains(out, `"milestone":"v1.0"`) { |
| 78 | 78 | t.Fatalf("issue show milestone: %s", out) |
| 79 | 79 | } |
| 80 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json") | |
| 81 | if !strings.Contains(out, `"milestone":"v1.0"`) { | |
| 82 | t.Fatalf("mr show milestone: %s", out) | |
| 83 | } | |
| 80 | 84 | |
| 81 | 85 | // Progress: 2 open (issue 1 + MR 1); closing the issue moves it. |
| 82 | 86 | out, _, _ = inst.ssh(t, aliceKey, "", "milestone", "list", "alice/app", "--json") |
internal/control/commitfile.go added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io" | |
| 6 | "strings" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/gitutil" | |
| 9 | "gitbay.org/gitbay/internal/policy" | |
| 10 | "gitbay.org/gitbay/internal/protocol" | |
| 11 | ) | |
| 12 | ||
| 13 | func init() { | |
| 14 | register(Command{ | |
| 15 | Path: []string{"repo", "commit-file"}, | |
| 16 | Summary: "write a file and commit it: repo commit-file <owner/name> <path> " + | |
| 17 | "--ref <branch> [--message <m>] [--file -]", | |
| 18 | ReadsStdin: true, | |
| 19 | Run: runCommitFile, | |
| 20 | }) | |
| 21 | } | |
| 22 | ||
| 23 | // maxCommitFileBytes bounds one edit. Large content belongs in a push, | |
| 24 | // not a single-file commit over the control plane. | |
| 25 | const maxCommitFileBytes = 1 << 20 | |
| 26 | ||
| 27 | // runCommitFile commits one file's contents to a branch. It exists so the | |
| 28 | // capability is reachable from every surface: the web's editor dispatches | |
| 29 | // this rather than calling git itself, which is what kept editing off the | |
| 30 | // CLI and the API. | |
| 31 | // | |
| 32 | // Commits made here are unsigned, because the server is authoring them. | |
| 33 | // A repository that requires verified signatures therefore refuses the | |
| 34 | // command rather than writing a commit its own policy would reject. | |
| 35 | func runCommitFile(c *Ctx, args []string) int { | |
| 36 | const usage = "repo commit-file <owner/name> <path> --ref <branch> [--message <m>] [--file -]" | |
| 37 | var rest []string | |
| 38 | var ref, message, file string | |
| 39 | for i := 0; i < len(args); i++ { | |
| 40 | switch args[i] { | |
| 41 | case "--ref", "--message", "--file": | |
| 42 | if i+1 >= len(args) { | |
| 43 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | |
| 44 | } | |
| 45 | switch args[i] { | |
| 46 | case "--ref": | |
| 47 | ref = args[i+1] | |
| 48 | case "--message": | |
| 49 | message = args[i+1] | |
| 50 | case "--file": | |
| 51 | file = args[i+1] | |
| 52 | } | |
| 53 | i++ | |
| 54 | default: | |
| 55 | if strings.HasPrefix(args[i], "--") { | |
| 56 | return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage) | |
| 57 | } | |
| 58 | rest = append(rest, args[i]) | |
| 59 | } | |
| 60 | } | |
| 61 | if len(rest) != 2 || ref == "" { | |
| 62 | return c.fail(protocol.ExitUsage, "usage: %s", usage) | |
| 63 | } | |
| 64 | repo, code := resolveRepo(c, rest[0], policy.CanWrite) | |
| 65 | if code >= 0 { | |
| 66 | return code | |
| 67 | } | |
| 68 | if code := refuseArchived(c, repo); code >= 0 { | |
| 69 | return code | |
| 70 | } | |
| 71 | filePath, ok := cleanRepoPath(rest[1]) | |
| 72 | if !ok || filePath == "" { | |
| 73 | return c.fail(protocol.ExitUsage, "path must stay inside the repository") | |
| 74 | } | |
| 75 | // The server authors this commit, so it cannot sign it. | |
| 76 | if repo.Settings.RequireSignedCommits { | |
| 77 | return c.fail(protocol.ExitDenied, | |
| 78 | "%s requires signed commits; this writes an unsigned one — push a signed commit instead", | |
| 79 | repo.Path()) | |
| 80 | } | |
| 81 | // A commit carries an identity, and an unverified address is not one. | |
| 82 | email, err := c.Store.PrimaryVerifiedEmail(c.User.ID) | |
| 83 | if err != nil { | |
| 84 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 85 | } | |
| 86 | if email == "" { | |
| 87 | return c.fail(protocol.ExitDenied, | |
| 88 | "commits carry your identity: your account needs a verified primary email") | |
| 89 | } | |
| 90 | ||
| 91 | var content []byte | |
| 92 | if file != "" { | |
| 93 | if file != "-" { | |
| 94 | return c.fail(protocol.ExitUsage, "--file only supports - (stdin)") | |
| 95 | } | |
| 96 | content, err = io.ReadAll(io.LimitReader(c.Stdin, maxCommitFileBytes)) | |
| 97 | if err != nil { | |
| 98 | return c.fail(protocol.ExitFailure, "reading content: %v", err) | |
| 99 | } | |
| 100 | } | |
| 101 | if message = strings.TrimSpace(message); message == "" { | |
| 102 | message = "edit " + filePath | |
| 103 | } | |
| 104 | ||
| 105 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 106 | sha, err := gitutil.CommitFileChange(dir, ref, filePath, content, | |
| 107 | c.User.Username, email, message) | |
| 108 | if err != nil { | |
| 109 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 110 | } | |
| 111 | c.Store.MarkMirrorsDirty(repo.ID, "push") | |
| 112 | ||
| 113 | d := struct { | |
| 114 | Path string `json:"path"` | |
| 115 | Ref string `json:"ref"` | |
| 116 | File string `json:"file"` | |
| 117 | SHA string `json:"sha"` | |
| 118 | }{repo.Path(), ref, filePath, sha} | |
| 119 | return c.emit(d, func(w io.Writer) { | |
| 120 | fmt.Fprintf(w, "committed %s on %s: %.10s\n", filePath, ref, sha) | |
| 121 | }) | |
| 122 | } | |
internal/control/mr.go +3 −1
| @@ -303,6 +303,7 @@ type mrOut struct { | ||
| 303 | 303 | TargetRef string `json:"target_ref"` |
| 304 | 304 | HeadSHA string `json:"head_sha"` |
| 305 | 305 | Body string `json:"body,omitempty"` |
| 306 | Milestone string `json:"milestone,omitempty"` | |
| 306 | 307 | CreatedAt string `json:"created_at"` |
| 307 | 308 | } |
| 308 | 309 | |
| @@ -316,7 +317,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | ||
| 316 | 317 | } |
| 317 | 318 | } |
| 318 | 319 | o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author, |
| 319 | Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt} | |
| 320 | Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, | |
| 321 | CreatedAt: m.CreatedAt} | |
| 320 | 322 | if withBody { |
| 321 | 323 | o.Body = m.Body |
| 322 | 324 | } |
internal/control/read.go +184
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "bytes" | |
| 4 | 5 | "encoding/base64" |
| 5 | 6 | "fmt" |
| 6 | 7 | "io" |
| 7 | 8 | "path" |
| 9 | "strconv" | |
| 8 | 10 | "strings" |
| 11 | "time" | |
| 9 | 12 | |
| 10 | 13 | "gitbay.org/gitbay/internal/gitutil" |
| 11 | 14 | "gitbay.org/gitbay/internal/policy" |
| @@ -25,6 +28,187 @@ func init() { | ||
| 25 | 28 | ReadOnly: true, |
| 26 | 29 | Run: runRepoCat, |
| 27 | 30 | }) |
| 31 | register(Command{ | |
| 32 | Path: []string{"repo", "blame"}, | |
| 33 | Summary: "attribute lines to commits: repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]", | |
| 34 | ReadOnly: true, | |
| 35 | Run: runRepoBlame, | |
| 36 | }) | |
| 37 | register(Command{ | |
| 38 | Path: []string{"repo", "refs"}, | |
| 39 | Summary: "list branches and tags: repo refs <owner/name>", | |
| 40 | ReadOnly: true, | |
| 41 | Run: runRepoRefs, | |
| 42 | }) | |
| 43 | } | |
| 44 | ||
| 45 | func runRepoRefs(c *Ctx, args []string) int { | |
| 46 | if len(args) != 1 { | |
| 47 | return c.fail(protocol.ExitUsage, "usage: repo refs <owner/name>") | |
| 48 | } | |
| 49 | repo, code := resolveRepo(c, args[0], policy.CanRead) | |
| 50 | if code >= 0 { | |
| 51 | return code | |
| 52 | } | |
| 53 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 54 | branches, err := gitutil.Refs(dir, "heads") | |
| 55 | if err != nil { | |
| 56 | return c.fail(protocol.ExitFailure, "listing branches: %v", err) | |
| 57 | } | |
| 58 | tags, err := gitutil.Refs(dir, "tags") | |
| 59 | if err != nil { | |
| 60 | return c.fail(protocol.ExitFailure, "listing tags: %v", err) | |
| 61 | } | |
| 62 | type refOut struct { | |
| 63 | Name string `json:"name"` | |
| 64 | SHA string `json:"sha"` | |
| 65 | } | |
| 66 | type out struct { | |
| 67 | Branches []refOut `json:"branches"` | |
| 68 | Tags []refOut `json:"tags"` | |
| 69 | } | |
| 70 | d := out{Branches: []refOut{}, Tags: []refOut{}} | |
| 71 | for _, ref := range branches { | |
| 72 | d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA}) | |
| 73 | } | |
| 74 | for _, ref := range tags { | |
| 75 | d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA}) | |
| 76 | } | |
| 77 | return c.emit(d, func(w io.Writer) { | |
| 78 | for _, ref := range d.Branches { | |
| 79 | fmt.Fprintf(w, "branch\t%s\t%.10s\n", ref.Name, ref.SHA) | |
| 80 | } | |
| 81 | for _, ref := range d.Tags { | |
| 82 | fmt.Fprintf(w, "tag\t%s\t%.10s\n", ref.Name, ref.SHA) | |
| 83 | } | |
| 84 | }) | |
| 85 | } | |
| 86 | ||
| 87 | // BlameSpan caps one blame request, and is the page size the web renders. | |
| 88 | // An unbounded blame on a large file is a slow query for every surface. | |
| 89 | const BlameSpan = 1000 | |
| 90 | ||
| 91 | func runRepoBlame(c *Ctx, args []string) int { | |
| 92 | const usage = "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]" | |
| 93 | var rest []string | |
| 94 | var ref string | |
| 95 | from, to := 0, 0 | |
| 96 | for i := 0; i < len(args); i++ { | |
| 97 | switch args[i] { | |
| 98 | case "--ref", "--from", "--to": | |
| 99 | if i+1 >= len(args) { | |
| 100 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | |
| 101 | } | |
| 102 | v := args[i+1] | |
| 103 | if args[i] == "--ref" { | |
| 104 | ref = v | |
| 105 | } else { | |
| 106 | n, err := strconv.Atoi(v) | |
| 107 | if err != nil || n < 1 { | |
| 108 | return c.fail(protocol.ExitUsage, "%s must be a positive line number", args[i]) | |
| 109 | } | |
| 110 | if args[i] == "--from" { | |
| 111 | from = n | |
| 112 | } else { | |
| 113 | to = n | |
| 114 | } | |
| 115 | } | |
| 116 | i++ | |
| 117 | default: | |
| 118 | if strings.HasPrefix(args[i], "--") { | |
| 119 | return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage) | |
| 120 | } | |
| 121 | rest = append(rest, args[i]) | |
| 122 | } | |
| 123 | } | |
| 124 | if len(rest) != 2 { | |
| 125 | return c.fail(protocol.ExitUsage, "usage: %s", usage) | |
| 126 | } | |
| 127 | repo, code := resolveRepo(c, rest[0], policy.CanRead) | |
| 128 | if code >= 0 { | |
| 129 | return code | |
| 130 | } | |
| 131 | filePath, ok := cleanRepoPath(rest[1]) | |
| 132 | if !ok || filePath == "" { | |
| 133 | return c.fail(protocol.ExitUsage, "path must stay inside the repository") | |
| 134 | } | |
| 135 | if ref == "" { | |
| 136 | ref = repo.DefaultBranch | |
| 137 | } | |
| 138 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 139 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | |
| 140 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | |
| 141 | } | |
| 142 | data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes) | |
| 143 | if err != nil { | |
| 144 | return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref) | |
| 145 | } | |
| 146 | if gitutil.IsBinary(data) { | |
| 147 | return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath) | |
| 148 | } | |
| 149 | total := bytes.Count(data, []byte("\n")) | |
| 150 | if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) { | |
| 151 | total++ | |
| 152 | } | |
| 153 | if total == 0 { | |
| 154 | return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref) | |
| 155 | } | |
| 156 | if from == 0 { | |
| 157 | from = 1 | |
| 158 | } | |
| 159 | if from > total { | |
| 160 | return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total) | |
| 161 | } | |
| 162 | if to == 0 || to > total { | |
| 163 | to = total | |
| 164 | } | |
| 165 | if to < from { | |
| 166 | return c.fail(protocol.ExitUsage, "--to must not precede --from") | |
| 167 | } | |
| 168 | // One span per call; a client pages with --from/--to. | |
| 169 | if to-from+1 > BlameSpan { | |
| 170 | to = from + BlameSpan - 1 | |
| 171 | } | |
| 172 | raw, err := gitutil.Blame(dir, ref, filePath, from, to) | |
| 173 | if err != nil { | |
| 174 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 175 | } | |
| 176 | ||
| 177 | type hunkOut struct { | |
| 178 | SHA string `json:"sha"` | |
| 179 | AuthorName string `json:"author_name"` | |
| 180 | AuthorEmail string `json:"author_email"` | |
| 181 | Date string `json:"date"` | |
| 182 | Summary string `json:"summary"` | |
| 183 | StartLine int `json:"start_line"` | |
| 184 | Lines []string `json:"lines"` | |
| 185 | } | |
| 186 | type out struct { | |
| 187 | Path string `json:"path"` | |
| 188 | Ref string `json:"ref"` | |
| 189 | File string `json:"file"` | |
| 190 | From int `json:"from"` | |
| 191 | To int `json:"to"` | |
| 192 | TotalLines int `json:"total_lines"` | |
| 193 | Hunks []hunkOut `json:"hunks"` | |
| 194 | } | |
| 195 | d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to, | |
| 196 | TotalLines: total, Hunks: []hunkOut{}} | |
| 197 | for _, h := range raw { | |
| 198 | d.Hunks = append(d.Hunks, hunkOut{ | |
| 199 | SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail, | |
| 200 | Date: time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339), | |
| 201 | Summary: h.Summary, | |
| 202 | StartLine: h.StartLine, Lines: h.Lines, | |
| 203 | }) | |
| 204 | } | |
| 205 | return c.emit(d, func(w io.Writer) { | |
| 206 | for _, h := range d.Hunks { | |
| 207 | for i, line := range h.Lines { | |
| 208 | fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line) | |
| 209 | } | |
| 210 | } | |
| 211 | }) | |
| 28 | 212 | } |
| 29 | 213 | |
| 30 | 214 | // readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape |
internal/httpd/accounts.go +9 −26
| @@ -474,37 +474,20 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User | ||
| 474 | 474 | } |
| 475 | 475 | ref := r.PathValue("ref") |
| 476 | 476 | filePath := strings.Trim(r.PathValue("path"), "/") |
| 477 | fail := func(msg string) { | |
| 477 | ||
| 478 | // Editing is a control command; the web supplies the form and lets | |
| 479 | // the registry enforce the rules — signed-commit policy, verified | |
| 480 | // identity, archived repositories — so every surface agrees on them. | |
| 481 | argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"} | |
| 482 | if message := strings.TrimSpace(r.FormValue("message")); message != "" { | |
| 483 | argv = append(argv, "--message", message) | |
| 484 | } | |
| 485 | if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok { | |
| 478 | 486 | s.render(w, "edit.html", editPage{ |
| 479 | 487 | basePage: s.baseFor(u), Repo: repo, |
| 480 | 488 | Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg, |
| 481 | 489 | }) |
| 482 | } | |
| 483 | // Web edits produce unsigned commits; a repo that requires signed | |
| 484 | // commits must refuse them rather than violate its own policy. | |
| 485 | if repo.Settings.RequireSignedCommits { | |
| 486 | fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead") | |
| 487 | return | |
| 488 | } | |
| 489 | email, err := s.st.PrimaryVerifiedEmail(u.ID) | |
| 490 | if err != nil { | |
| 491 | fail("internal error") | |
| 492 | return | |
| 493 | } | |
| 494 | if email == "" { | |
| 495 | fail("commits carry your identity: your account needs a verified primary email") | |
| 496 | return | |
| 497 | } | |
| 498 | message := strings.TrimSpace(r.FormValue("message")) | |
| 499 | if message == "" { | |
| 500 | message = "edit " + filePath | |
| 501 | } | |
| 502 | dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 503 | if _, err := gitutil.CommitFileChange(dir, ref, filePath, | |
| 504 | []byte(r.FormValue("content")), u.Username, email, message); err != nil { | |
| 505 | fail(err.Error()) | |
| 506 | 490 | return |
| 507 | 491 | } |
| 508 | s.st.MarkMirrorsDirty(repo.ID, "push") | |
| 509 | 492 | http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther) |
| 510 | 493 | } |
internal/httpd/control.go +38
| @@ -65,6 +65,44 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg | ||
| 65 | 65 | return m, code == protocol.ExitOK |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | // runControlInto runs a command in JSON mode and decodes its data into | |
| 69 | // target. Read handlers use it so the web renders exactly what the CLI | |
| 70 | // and the API return, rather than reaching past the registry into git. | |
| 71 | func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) { | |
| 72 | var stdout, stderr bytes.Buffer | |
| 73 | ctx := &control.Ctx{ | |
| 74 | User: u, | |
| 75 | Source: "web", | |
| 76 | Scope: "full", | |
| 77 | Store: s.st, | |
| 78 | Cfg: s.cfg, | |
| 79 | Stdin: strings.NewReader(""), | |
| 80 | Stdout: &stdout, | |
| 81 | Stderr: &stderr, | |
| 82 | JSON: true, | |
| 83 | ViaAPI: true, | |
| 84 | } | |
| 85 | code := control.Dispatch(ctx, argv) | |
| 86 | var env struct { | |
| 87 | Data json.RawMessage `json:"data"` | |
| 88 | Error string `json:"error"` | |
| 89 | } | |
| 90 | json.Unmarshal(stdout.Bytes(), &env) | |
| 91 | if code != protocol.ExitOK { | |
| 92 | m := env.Error | |
| 93 | if m == "" { | |
| 94 | m = strings.TrimSpace(stderr.String()) | |
| 95 | } | |
| 96 | return m, false | |
| 97 | } | |
| 98 | if len(env.Data) > 0 { | |
| 99 | if err := json.Unmarshal(env.Data, target); err != nil { | |
| 100 | return "unreadable response", false | |
| 101 | } | |
| 102 | } | |
| 103 | return "", true | |
| 104 | } | |
| 105 | ||
| 68 | 106 | // runControlJSON runs a command in JSON mode and returns its data object. |
| 69 | 107 | // In JSON mode a failure is an envelope carrying the message rather than |
| 70 | 108 | // stderr text, so both paths are read from the same envelope. |
internal/httpd/web.go +67 −38
| @@ -725,10 +725,6 @@ func markMatch(text, q string) template.HTML { | ||
| 725 | 725 | return template.HTML(b.String()) |
| 726 | 726 | } |
| 727 | 727 | |
| 728 | // blamePageSize caps how many lines one blame page renders; blame is a | |
| 729 | // per-line subprocess cost, so large files paginate. | |
| 730 | const blamePageSize = 1000 | |
| 731 | ||
| 732 | 728 | func (s *Server) blame(w http.ResponseWriter, r *http.Request) { |
| 733 | 729 | p, ok := s.repoFor(w, r, r.PathValue("ref")) |
| 734 | 730 | if !ok { |
| @@ -736,16 +732,48 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) { | ||
| 736 | 732 | } |
| 737 | 733 | p.Tab = "files" |
| 738 | 734 | filePath := strings.Trim(r.PathValue("path"), "/") |
| 739 | data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes) | |
| 740 | if err != nil { | |
| 741 | s.notFound(w, r) | |
| 742 | return | |
| 735 | ||
| 736 | // Blame is a control command; the web renders what it returns rather | |
| 737 | // than shelling out to git itself, so all three surfaces agree. | |
| 738 | page := 1 | |
| 739 | if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 { | |
| 740 | page = n | |
| 741 | } | |
| 742 | from := (page-1)*control.BlameSpan + 1 | |
| 743 | ||
| 744 | var out struct { | |
| 745 | From int `json:"from"` | |
| 746 | To int `json:"to"` | |
| 747 | TotalLines int `json:"total_lines"` | |
| 748 | Hunks []struct { | |
| 749 | SHA string `json:"sha"` | |
| 750 | AuthorName string `json:"author_name"` | |
| 751 | AuthorEmail string `json:"author_email"` | |
| 752 | Date string `json:"date"` | |
| 753 | Summary string `json:"summary"` | |
| 754 | StartLine int `json:"start_line"` | |
| 755 | Lines []string `json:"lines"` | |
| 756 | } `json:"hunks"` | |
| 757 | } | |
| 758 | argv := []string{"repo", "blame", p.Repo.Path(), filePath, | |
| 759 | "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)} | |
| 760 | var viewer store.User | |
| 761 | if s.cfg.Web.Mode == "accounts" { | |
| 762 | viewer = s.viewer(r) | |
| 743 | 763 | } |
| 744 | total := bytes.Count(data, []byte("\n")) | |
| 745 | if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) { | |
| 746 | total++ | |
| 764 | msg, ok := s.runControlInto(viewer, argv, &out) | |
| 765 | ||
| 766 | // A binary or empty file is a refusal, not a 404: the page still | |
| 767 | // renders and says why there is nothing to attribute. | |
| 768 | binary := false | |
| 769 | if !ok { | |
| 770 | if strings.Contains(msg, "is binary") { | |
| 771 | binary = true | |
| 772 | } else { | |
| 773 | s.notFound(w, r) | |
| 774 | return | |
| 775 | } | |
| 747 | 776 | } |
| 748 | binary := gitutil.IsBinary(data) | |
| 749 | 777 | |
| 750 | 778 | type hunkView struct { |
| 751 | 779 | gitutil.BlameHunk |
| @@ -755,36 +783,37 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) { | ||
| 755 | 783 | Numbered []numberedLine |
| 756 | 784 | } |
| 757 | 785 | var hunks []hunkView |
| 758 | page, pages := 1, (total+blamePageSize-1)/blamePageSize | |
| 786 | sigs := map[string]sigView{} | |
| 787 | for _, h := range out.Hunks { | |
| 788 | v, seen := sigs[h.SHA] | |
| 789 | if !seen { | |
| 790 | v, _ = s.sigFor(p.Repo, p.Dir, h.SHA) | |
| 791 | sigs[h.SHA] = v | |
| 792 | } | |
| 793 | date := h.Date | |
| 794 | if t, err := time.Parse(time.RFC3339, h.Date); err == nil { | |
| 795 | date = t.Format("2006-01-02") | |
| 796 | } | |
| 797 | hv := hunkView{ | |
| 798 | BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName, | |
| 799 | AuthorEmail: h.AuthorEmail, Summary: h.Summary, | |
| 800 | StartLine: h.StartLine, Lines: h.Lines}, | |
| 801 | ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v, | |
| 802 | } | |
| 803 | for i, l := range h.Lines { | |
| 804 | hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l}) | |
| 805 | } | |
| 806 | hunks = append(hunks, hv) | |
| 807 | } | |
| 808 | ||
| 809 | pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan | |
| 759 | 810 | if pages == 0 { |
| 760 | 811 | pages = 1 |
| 761 | 812 | } |
| 762 | if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages { | |
| 763 | page = n | |
| 764 | } | |
| 765 | if !binary && total > 0 { | |
| 766 | start := (page-1)*blamePageSize + 1 | |
| 767 | end := min(total, page*blamePageSize) | |
| 768 | raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end) | |
| 769 | if err != nil { | |
| 770 | s.notFound(w, r) | |
| 771 | return | |
| 772 | } | |
| 773 | sigs := map[string]sigView{} | |
| 774 | for _, h := range raw { | |
| 775 | v, ok := sigs[h.SHA] | |
| 776 | if !ok { | |
| 777 | v, _ = s.sigFor(p.Repo, p.Dir, h.SHA) | |
| 778 | sigs[h.SHA] = v | |
| 779 | } | |
| 780 | hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10], | |
| 781 | Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v} | |
| 782 | for i, l := range h.Lines { | |
| 783 | hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l}) | |
| 784 | } | |
| 785 | hunks = append(hunks, hv) | |
| 786 | } | |
| 813 | if page > pages { | |
| 814 | page = pages | |
| 787 | 815 | } |
| 816 | ||
| 788 | 817 | cs := crumbs(p, "blame", filePath) |
| 789 | 818 | base := "" |
| 790 | 819 | if len(cs) > 0 { |