Add branch/tag archive download (.tar.gz / .zip)
New gitexec.Archive (git archive, hardened with --end-of-options like every other ref-accepting git call in this codebase), admin.Ops GetRepoArchive, threaded through adminrpc with its own longer timeout. ?ref= is validated against the repo's real branches/tags before it reaches git — an unknown value 404s, same treatment as the branch dropdown's ?branch=. Capped at 100MB: the admin RPC transport buffers the whole response as base64 JSON (no streaming), and a truncated tar/zip is corrupt, so past the cap it's a hard refusal, not a partial file the way CommitDiff can truncate a text patch. UI: a "Download" dropdown next to the branch picker, and a download link on each tag badge.
13 files changed
+236 −4
M
CHANGELOG.md
+4 −0
M
ROADMAP.md
+0 −2
M
cmd/gitfed-web/handlers_repo.go
+73 −1
M
cmd/gitfed-web/render.go
+3 −1
M
cmd/gitfed-web/routes.go
+1 −0
M
internal/admin/admin.go
+12 −0
M
internal/adminrpc/client.go
+11 −0
M
internal/adminrpc/protocol.go
+15 −0
M
internal/adminrpc/server.go
+8 −0
M
internal/gitexec/gitexec.go
+48 −0
M
internal/gitexec/gitexec_test.go
+59 −0
M
internal/i18n/strings_en.go
+1 −0
M
internal/i18n/strings_fr.go
+1 −0
CHANGELOG.md
@@ -2,6 +2,10 @@
A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version.
+## 1.2.21
+
+- Download a branch or tag as `.tar.gz`/`.zip` (`git archive` under the hood) — a "Download" dropdown next to the branch picker for the current branch, and a download link on each tag. `?ref=` is validated against the repo's real branches/tags before reaching git, same "unknown value 404s" treatment as the branch dropdown. New `GetRepoArchive` on `admin.Ops`; capped at 100MB since the admin RPC transport buffers the whole response as base64 JSON rather than streaming — a truncated archive would just be a corrupt file, so it's a hard refusal past the cap, not a partial result.
+
## 1.2.20
- Repos can now have a short description (Settings → Visibility & topics, 200 chars), shown on the repo page under the title and matched by `/search` in addition to name and topics — the roadmap assumed this field already existed; it didn't, so this adds `store.Repo.Description` end to end (new `SetRepoDescription` on `admin.Ops`, threaded through `adminrpc`).
ROADMAP.md
@@ -119,8 +119,6 @@ celui-là la prochaine fois qu'on rouvre ce document.
### Organisation à plus grande échelle
-- **Export/archive** d'une branche ou d'un tag en `.tar.gz`/`.zip`
- via `git archive`. *(effort faible)*.
- **ACL par équipe/organisation**, en plus de l'ACL par personne
actuelle. *(effort élevé — nouveau modèle de données, faible valeur
tant que l'instance reste utilisée par peu de monde)*.
cmd/gitfed-web/handlers_repo.go
@@ -5,6 +5,7 @@ import (
"html/template"
"net/http"
"net/url"
+ "strconv"
"strings"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec"
@@ -138,6 +139,15 @@ var repoTpl = newTpl("repo", `
</div>
</details>
{{end}}
+ {{if not .Empty}}
+ <details class="gf-branch-dropdown">
+ <summary class="gf-btn">{{icon "download"}} {{t .Lang "repo.download"}}</summary>
+ <div class="gf-branch-menu">
+ <a href="/repo-archive/{{.Repo.Name}}?ref={{.Branch}}&format=tar.gz">{{.Branch}}.tar.gz</a>
+ <a href="/repo-archive/{{.Repo.Name}}?ref={{.Branch}}&format=zip">{{.Branch}}.zip</a>
+ </div>
+ </details>
+ {{end}}
{{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}{{if .CommitCount}} ({{.CommitCount}}){{end}}</a>{{end}}
{{if not .Empty}}<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{icon "branch"}} {{t .Lang "mr.list_title"}}</a>{{end}}
{{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}}
@@ -198,7 +208,7 @@ var repoTpl = newTpl("repo", `
{{end}}
{{if .Tags}}
-<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section>
+<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<a href="/repo-archive/{{$.Repo.Name}}?ref={{.}}&format=tar.gz" class="badge plain" title="{{t $.Lang "repo.download"}}">{{icon "download"}} {{.}}</a> {{end}}</section>
{{end}}
{{if .ReadmeHTML}}
@@ -452,6 +462,68 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
}
+// handleRepoArchive streams a tar.gz/zip snapshot of a branch or tag —
+// ?ref= is validated against the repo's real branches and tags before it
+// ever reaches a git subprocess (see gitexec.Archive's contract), same
+// "unknown value 404s, no silent fallback" treatment as the branch
+// dropdown's ?branch=.
+func (s *server) handleRepoArchive(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+
+ repo, err := s.ops.GetRepo(name)
+ if err != nil || !s.canView(r, repo) {
+ http.NotFound(w, r)
+ return
+ }
+
+ format := r.URL.Query().Get("format")
+ if format != "tar.gz" && format != "zip" {
+ http.NotFound(w, r)
+ return
+ }
+
+ ref := r.URL.Query().Get("ref")
+ if ref == "" {
+ http.NotFound(w, r)
+ return
+ }
+ branches, err := s.ops.ListBranches(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+ tags, err := s.ops.ListRepoTags(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+ if !containsBranch(branches, ref) && !containsBranch(tags, ref) {
+ http.NotFound(w, r)
+ return
+ }
+
+ data, err := s.ops.GetRepoArchive(name, ref, format)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+
+ contentType := "application/gzip"
+ if format == "zip" {
+ contentType = "application/zip"
+ }
+ // Slashes in the owner/repo name or in a branch like "feature/x" are
+ // cosmetic-only here — purely for the suggested filename, not passed
+ // to git (that already happened, safely, above).
+ base := strings.ReplaceAll(name, "/", "-") + "-" + strings.ReplaceAll(ref, "/", "-")
+ filename := base + "." + format
+
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+ _, _ = w.Write(data)
+}
+
func renderFileContent(repo, dir, filename, content string) (template.HTML, error) {
if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") {
return renderRepoMarkdown(repo, dir, content)
cmd/gitfed-web/render.go
@@ -164,6 +164,7 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid
<symbol id="ic-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3v2.3M12 18.7V21M21 12h-2.3M5.3 12H3M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6M18.4 18.4l-1.6-1.6M7.2 7.2 5.6 5.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="ic-moon" viewBox="0 0 24 24"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="ic-auto" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor"/></symbol>
+<symbol id="ic-download" viewBox="0 0 24 24"><path d="M12 4v11M7.5 11.5 12 16l4.5-4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 18.5h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
</defs>
</svg>`
@@ -694,10 +695,11 @@ const shellHeadSrc = `<!doctype html>
.msg.ok { background: var(--ok-bg); color: var(--ok-fg); }
.msg.err { background: var(--danger-bg); color: var(--danger-fg); }
.msg.warn { background: var(--pending-bg); color: var(--pending-fg); }
- .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); }
+ .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); text-decoration: none; }
.badge.pending { background: var(--pending-bg); color: var(--pending-fg); }
.badge.trusted { background: var(--ok-bg); color: var(--ok-fg); }
.badge.plain { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); margin-right: 0.3rem; }
+ a.badge.plain:hover { background: var(--surface-3); color: var(--text); border-color: var(--border-strong); }
.badge.danger { background: var(--danger-bg); color: var(--danger-fg); }
.muted { color: var(--text-dim); font-size: 0.85rem; }
cmd/gitfed-web/routes.go
@@ -7,6 +7,7 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", s.handleLanding)
mux.HandleFunc("GET /r/{repo...}", s.handleRepoView)
mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob)
+ mux.HandleFunc("GET /repo-archive/{repo...}", s.handleRepoArchive)
mux.HandleFunc("GET /repo-commits/{repo...}", s.handleRepoCommits)
mux.HandleFunc("GET /repo-commit/{repo...}", s.handleRepoCommitDetail)
mux.HandleFunc("GET /repo-mrs/{repo...}", s.handleMRList)
internal/admin/admin.go
@@ -56,6 +56,7 @@ type Ops interface {
GetRepoReadme(name string) (content string, found bool, err error)
GetRepoLicense(name string) (content, filename string, found bool, err error)
ListRepoTags(name string) ([]string, error)
+ GetRepoArchive(name, ref, format string) (data []byte, err error)
ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error)
ListRepoTreeAtRef(name, ref, path string) (entries []gitexec.TreeEntry, found bool, err error)
GetRepoFile(name, path string) (content string, found bool, err error)
@@ -414,6 +415,17 @@ func (a *Admin) ListRepoTags(name string) ([]string, error) {
return gitexec.ListTags(repo.Path)
}
+// GetRepoArchive returns a tar.gz/zip snapshot of ref (a branch or tag).
+// The caller must have already validated ref against the repo's real
+// branches/tags (see gitexec.Archive's contract).
+func (a *Admin) GetRepoArchive(name, ref, format string) ([]byte, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return nil, err
+ }
+ return gitexec.Archive(repo.Path, ref, format)
+}
+
func (a *Admin) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
internal/adminrpc/client.go
@@ -126,6 +126,17 @@ func (c *Client) ImportRepo(name, sourceURL, ownerUsername string) error {
return c.callWithTimeout(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil, importRPCTimeout)
}
+// archiveRPCTimeout gives git archive (a local, CPU-bound tar/zip of
+// however much history+content ref pulls in, up to archiveMaxBytes) more
+// headroom than the default 5s every other, near-instant admin call gets.
+const archiveRPCTimeout = 60 * time.Second
+
+func (c *Client) GetRepoArchive(name, ref, format string) ([]byte, error) {
+ var out archiveResult
+ err := c.callWithTimeout(methodGetRepoArchive, archiveArgs{Name: name, Ref: ref, Format: format}, &out, archiveRPCTimeout)
+ return out.Data, err
+}
+
func (c *Client) DeleteRepo(name string) error {
err := c.call(methodDeleteRepo, nameArgs{Name: name}, nil)
return err
internal/adminrpc/protocol.go
@@ -37,6 +37,7 @@ const (
methodGetRepoReadme = "GetRepoReadme"
methodGetRepoLicense = "GetRepoLicense"
methodListRepoTags = "ListRepoTags"
+ methodGetRepoArchive = "GetRepoArchive"
methodListRepoTree = "ListRepoTree"
methodListRepoTreeAtRef = "ListRepoTreeAtRef"
methodGetRepoFile = "GetRepoFile"
@@ -154,6 +155,20 @@ type setDescriptionArgs struct {
Description string `json:"description"`
}
+type archiveArgs struct {
+ Name string `json:"name"`
+ Ref string `json:"ref"`
+ Format string `json:"format"`
+}
+
+// archiveResult's Data is base64-encoded automatically by encoding/json
+// (the standard behavior for a []byte field) — see gitexec.go's
+// archiveMaxBytes comment for why this stays a single buffered response
+// rather than a streamed one.
+type archiveResult struct {
+ Data []byte `json:"data"`
+}
+
type readmeResult struct {
Content string `json:"content"`
Found bool `json:"found"`
internal/adminrpc/server.go
@@ -219,6 +219,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
tags, err := s.ops.ListRepoTags(a.Name)
return listTagsResult{Tags: tags}, err
+ case methodGetRepoArchive:
+ var a archiveArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ data, err := s.ops.GetRepoArchive(a.Name, a.Ref, a.Format)
+ return archiveResult{Data: data}, err
+
case methodListRepoTree:
var a pathArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
@@ -348,6 +348,54 @@ func ListTags(repoPath string) ([]string, error) {
return tags, nil
}
+// archiveMaxBytes caps how large an archive Archive will return. Needed
+// because — unlike a normal git subprocess call in this file — the result
+// crosses the admin RPC boundary as a single buffered, base64-encoded JSON
+// response (internal/adminrpc has no streaming transport; see
+// internal/adminrpc/client.go's call()), so an unbounded archive would mean
+// an unbounded in-memory buffer on both ends. Refusing outright past this
+// size is deliberate: a truncated tar/zip is corrupt and useless, so there
+// is no sensible partial result the way CommitDiff can return a truncated
+// patch.
+const archiveMaxBytes = 100 * 1024 * 1024
+
+// Archive returns a tar.gz or zip snapshot of ref (a branch or tag name —
+// the caller is responsible for ref being real, same contract as
+// ListTreeAtRef/ReadFileAtRef) via `git archive`, hardened with
+// --end-of-options for the same reason as everywhere else a ref reaches a
+// git subprocess.
+func Archive(repoPath, ref, format string) ([]byte, error) {
+ if format != "tar.gz" && format != "zip" {
+ return nil, fmt.Errorf("gitexec: unsupported archive format %q", format)
+ }
+
+ cmd := exec.Command("git", "--git-dir="+repoPath, "archive", "--format="+format, "--end-of-options", ref)
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+
+ data, readErr := io.ReadAll(io.LimitReader(stdout, archiveMaxBytes+1))
+ if readErr != nil {
+ _ = cmd.Wait()
+ return nil, fmt.Errorf("gitexec: archive %s: %w", ref, readErr)
+ }
+ if len(data) > archiveMaxBytes {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ return nil, fmt.Errorf("gitexec: archive for %s exceeds %d bytes, refusing", ref, archiveMaxBytes)
+ }
+ if err := cmd.Wait(); err != nil {
+ return nil, fmt.Errorf("gitexec: archive %s: %w: %s", ref, err, stderr.String())
+ }
+ return data, nil
+}
+
// Commit is one entry in a repo's history, as shown by ListCommits.
type Commit struct {
Hash string `json:"hash"`
internal/gitexec/gitexec_test.go
@@ -362,6 +362,65 @@ func TestListTreeAtRefAndReadFileAtRef(t *testing.T) {
}
}
+func TestArchive(t *testing.T) {
+ tmp := t.TempDir()
+ barePath := filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+
+ work := filepath.Join(tmp, "work")
+ if err := os.Mkdir(work, 0755); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "init", "-q", "-b", "main")
+ if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("hi\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", "hello.txt")
+ run(t, work, "git", "commit", "-q", "-m", "initial")
+ run(t, work, "git", "tag", "v1.0.0")
+ run(t, work, "git", "remote", "add", "origin", barePath)
+ run(t, work, "git", "push", "-q", "origin", "main", "--tags")
+
+ for _, format := range []string{"tar.gz", "zip"} {
+ t.Run(format, func(t *testing.T) {
+ for _, ref := range []string{"main", "v1.0.0"} {
+ data, err := Archive(barePath, ref, format)
+ if err != nil {
+ t.Fatalf("Archive(%s, %s): %v", ref, format, err)
+ }
+ if len(data) == 0 {
+ t.Fatalf("Archive(%s, %s) returned no data", ref, format)
+ }
+ // Real magic-byte check, not just "non-empty" — gzip
+ // starts 0x1f 0x8b, zip starts "PK".
+ switch format {
+ case "tar.gz":
+ if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
+ t.Errorf("Archive(%s, tar.gz) doesn't look like gzip: % x", ref, data[:min(4, len(data))])
+ }
+ case "zip":
+ if len(data) < 2 || data[0] != 'P' || data[1] != 'K' {
+ t.Errorf("Archive(%s, zip) doesn't look like a zip: % x", ref, data[:min(4, len(data))])
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestArchiveUnsupportedFormat(t *testing.T) {
+ tmp := t.TempDir()
+ barePath := filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+ if _, err := Archive(barePath, "main", "tar.bz2"); err == nil {
+ t.Fatal("expected an error for an unsupported format")
+ }
+}
+
func TestDefaultBranchNameViaHEAD(t *testing.T) {
tmp := t.TempDir()
barePath := filepath.Join(tmp, "repo.git")
internal/i18n/strings_en.go
@@ -283,6 +283,7 @@ var en = map[string]string{
"repo.file": "file",
"repo.nothing_here": "Nothing here.",
"repo.tags": "Tags",
+ "repo.download": "Download",
"repo.commits_title": "Commits",
"repo.commits_empty": "No commits yet.",
"repo.commit_label": "commit",
internal/i18n/strings_fr.go
@@ -283,6 +283,7 @@ var fr = map[string]string{
"repo.file": "fichier",
"repo.nothing_here": "Rien ici.",
"repo.tags": "Tags",
+ "repo.download": "Télécharger",
"repo.commits_title": "Commits",
"repo.commits_empty": "Aucun commit pour le moment.",
"repo.commit_label": "commit",