vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 59m ago
4b8c9e1d
| 1 | package client |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io/fs" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | |
| 10 | "rodent.software/vole/core/git" |
| 11 | ) |
| 12 | |
| 13 | // SiteDiff counts what a deploy would change, so a dry run says something more |
| 14 | // useful than "it would push". |
| 15 | type SiteDiff struct { |
| 16 | Added int |
| 17 | Changed int |
| 18 | Removed int |
| 19 | } |
| 20 | |
| 21 | // Empty reports whether the deploy would change nothing. |
| 22 | func (d SiteDiff) Empty() bool { return d.Added == 0 && d.Changed == 0 && d.Removed == 0 } |
| 23 | |
| 24 | // String renders the counts for a status line. |
| 25 | func (d SiteDiff) String() string { |
| 26 | var parts []string |
| 27 | for _, p := range []struct { |
| 28 | n int |
| 29 | verb string |
| 30 | }{{d.Added, "added"}, {d.Changed, "changed"}, {d.Removed, "removed"}} { |
| 31 | if p.n > 0 { |
| 32 | parts = append(parts, fmt.Sprintf("%d %s", p.n, p.verb)) |
| 33 | } |
| 34 | } |
| 35 | if len(parts) == 0 { |
| 36 | return "no changes" |
| 37 | } |
| 38 | return strings.Join(parts, ", ") |
| 39 | } |
| 40 | |
| 41 | // DeploySite publishes a build directory as a snapshot of the branch. A site |
| 42 | // published by a workflow and a site published by a person have to be the same |
| 43 | // commit shape, so both use this function. |
| 44 | // |
| 45 | // dryRun stops before committing; deployed reports whether anything was pushed. |
| 46 | func DeploySite(b *Branch, dir, message string, dryRun bool) (diff SiteDiff, files int, deployed bool, err error) { |
| 47 | contents, err := ReadSiteDir(dir) |
| 48 | if err != nil { |
| 49 | return diff, 0, false, err |
| 50 | } |
| 51 | if len(contents) == 0 { |
| 52 | return diff, 0, false, fmt.Errorf("%s has no files to publish; `vole pages unpublish` takes a site down", dir) |
| 53 | } |
| 54 | // Deploy compares against what the server has, not against the local branch: |
| 55 | // after an unpublish the local ref still holds the site that was taken down, |
| 56 | // and a stale comparison would report "already published" for a 404. |
| 57 | tip, err := SyncSiteRef(b.Repo, b.Remote, b.Ref) |
| 58 | if err != nil { |
| 59 | return diff, len(contents), false, err |
| 60 | } |
| 61 | current := map[string][]byte{} |
| 62 | if tip != "" { |
| 63 | if current, err = b.Repo.FlatTree(tip); err != nil { |
| 64 | return diff, len(contents), false, err |
| 65 | } |
| 66 | } |
| 67 | diff = CompareSite(current, contents) |
| 68 | if dryRun || diff.Empty() { |
| 69 | return diff, len(contents), false, nil |
| 70 | } |
| 71 | if message == "" { |
| 72 | message = fmt.Sprintf("site: publish %d file(s)", len(contents)) |
| 73 | } |
| 74 | apply := func() error { return CommitSite(b.Repo, b.Ref, contents, message, b.Author) } |
| 75 | if err := apply(); err != nil { |
| 76 | return diff, len(contents), false, err |
| 77 | } |
| 78 | if err := b.CommitAndPush(false, apply); err != nil { |
| 79 | return diff, len(contents), false, err |
| 80 | } |
| 81 | return diff, len(contents), true, nil |
| 82 | } |
| 83 | |
| 84 | // ReadSiteDir reads a directory into a flat path -> content map, which is exactly |
| 85 | // what a git tree is. `.git` is skipped, symlinks are skipped rather than followed |
| 86 | // (a site is bytes, and a link out of the directory would publish something the |
| 87 | // author never chose), and dotfiles are kept because `.nojekyll` and |
| 88 | // `.well-known/` are part of sites. |
| 89 | func ReadSiteDir(root string) (map[string][]byte, error) { |
| 90 | st, err := os.Stat(root) |
| 91 | if err != nil { |
| 92 | return nil, err |
| 93 | } |
| 94 | if !st.IsDir() { |
| 95 | return nil, fmt.Errorf("%s is not a directory", root) |
| 96 | } |
| 97 | files := map[string][]byte{} |
| 98 | err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { |
| 99 | if err != nil { |
| 100 | return err |
| 101 | } |
| 102 | rel, err := filepath.Rel(root, p) |
| 103 | if err != nil { |
| 104 | return err |
| 105 | } |
| 106 | if rel == "." { |
| 107 | return nil |
| 108 | } |
| 109 | rel = filepath.ToSlash(rel) |
| 110 | if d.IsDir() { |
| 111 | if d.Name() == ".git" { |
| 112 | return fs.SkipDir |
| 113 | } |
| 114 | return nil |
| 115 | } |
| 116 | info, err := d.Info() |
| 117 | if err != nil { |
| 118 | return err |
| 119 | } |
| 120 | if info.Mode()&os.ModeSymlink != 0 { |
| 121 | fmt.Fprintf(os.Stderr, "skipping symlink %s\n", rel) |
| 122 | return nil |
| 123 | } |
| 124 | if !info.Mode().IsRegular() { |
| 125 | return nil |
| 126 | } |
| 127 | b, err := os.ReadFile(p) |
| 128 | if err != nil { |
| 129 | return err |
| 130 | } |
| 131 | files[rel] = b |
| 132 | return nil |
| 133 | }) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 137 | return files, nil |
| 138 | } |
| 139 | |
| 140 | // SyncSiteRef makes the local branch agree with the server and returns the |
| 141 | // server's tip, or "" when the branch is not published. |
| 142 | // |
| 143 | // A local branch is a cache of a ref, and this one is special: unpublishing drops |
| 144 | // it on the server while the clone keeps it, so a deploy that trusted the clone |
| 145 | // would say "already published" and leave the site 404ing. When the server has no |
| 146 | // such branch the local one is pruned, which also means a re-publish starts a |
| 147 | // fresh branch rather than resurrecting the one that was taken down. |
| 148 | func SyncSiteRef(g *git.Repo, remote, ref string) (string, error) { |
| 149 | tip, err := LSRemote(g.Dir, remote, ref) |
| 150 | if err != nil { |
| 151 | return "", err |
| 152 | } |
| 153 | local, _ := g.RevParse(ref) |
| 154 | switch { |
| 155 | case tip == "" && local != "": |
| 156 | if err := g.DeleteRef(ref); err != nil { |
| 157 | return "", fmt.Errorf("drop the stale local %s: %w", ref, err) |
| 158 | } |
| 159 | case tip != "" && local != tip: |
| 160 | if err := g.UpdateRef(ref, tip); err != nil { |
| 161 | return "", fmt.Errorf("move the local %s to the server's tip: %w", ref, err) |
| 162 | } |
| 163 | } |
| 164 | return tip, nil |
| 165 | } |
| 166 | |
| 167 | // CommitSite replaces the branch's tree with files. CommitFiles starts from the |
| 168 | // current tree on every attempt, so a lost push race re-applies the snapshot onto |
| 169 | // the new tip instead of merging two builds. |
| 170 | func CommitSite(g *git.Repo, ref string, files map[string][]byte, msg, author string) error { |
| 171 | _, err := git.CommitFiles(g, ref, func(cur map[string][]byte) error { |
| 172 | for k := range cur { |
| 173 | delete(cur, k) |
| 174 | } |
| 175 | for k, v := range files { |
| 176 | cur[k] = v |
| 177 | } |
| 178 | return nil |
| 179 | }, msg, author) |
| 180 | return err |
| 181 | } |
| 182 | |
| 183 | // CompareSite reports what a deploy would change. |
| 184 | func CompareSite(old, next map[string][]byte) SiteDiff { |
| 185 | var d SiteDiff |
| 186 | for path, content := range next { |
| 187 | prev, ok := old[path] |
| 188 | switch { |
| 189 | case !ok: |
| 190 | d.Added++ |
| 191 | case string(prev) != string(content): |
| 192 | d.Changed++ |
| 193 | } |
| 194 | } |
| 195 | for path := range old { |
| 196 | if _, ok := next[path]; !ok { |
| 197 | d.Removed++ |
| 198 | } |
| 199 | } |
| 200 | return d |
| 201 | } |
| 202 |