Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Blame · client/site.go

Line attribution on main · 201 line(s)

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 }