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.
|
c5902451
Keenan Nemetz
16d ago
|
1 | package client |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io/fs" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
10 | "rodent.software/vole/core/git" |
|
c5902451
Keenan Nemetz
16d ago
|
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 | } |