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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 59m ago 4b8c9e1d
history blame raw 5.6 KiB
1package client
2
3import (
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".
15type SiteDiff struct {
16 Added int
17 Changed int
18 Removed int
19}
20
21// Empty reports whether the deploy would change nothing.
22func (d SiteDiff) Empty() bool { return d.Added == 0 && d.Changed == 0 && d.Removed == 0 }
23
24// String renders the counts for a status line.
25func (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.
46func 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.
89func 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.
148func 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.
170func 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.
184func 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