vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
refactor
c5902451905acdaa478f8476844ccd4a510d9ca9 · Keenan Nemetz · 17d ago · parent ec2aba4b
client/site.go +201
| @@ -0,0 +1,201 @@ | |||
| 1 | package client | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io/fs" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "github.com/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 | } | ||