uniweb 0.13.5 → 0.13.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Git awareness for the sync verbs.
3
+ *
4
+ * The CLI's file-based workflow is for people who keep their content in git, so
5
+ * git is the right place to look before doing something destructive to the working
6
+ * tree — rather than building a merge engine to solve a problem the user's VCS
7
+ * already solves better.
8
+ *
9
+ * DETECTED, NEVER REQUIRED. A project may legitimately have no repo, and — more
10
+ * commonly — may live *inside* a larger one: `uniweb create` already skips
11
+ * `git init` when it finds itself inside a work tree ("common for
12
+ * monorepos/workspaces"), and whole scopes of real projects are versioned by a
13
+ * parent repo rather than being repo roots themselves. So the question is never
14
+ * "is this directory a git root" but "is this path tracked by some repo", which is
15
+ * the question git itself answers. Callers degrade to an explicit confirmation
16
+ * when the answer is no; sync already requires an account, and a second hard
17
+ * dependency for a safety net we can provide anyway is a bad trade.
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process'
21
+ import { readFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import yaml from 'js-yaml'
24
+
25
+ /**
26
+ * The files and directories `uniweb pull` writes into.
27
+ *
28
+ * ONE definition, shared by the guard that blocks a pull and the message that
29
+ * tells someone how to recover from a refused push — a second copy would drift,
30
+ * and a stale list here means either a guard that misses or advice that is wrong.
31
+ * `paths:` can relocate the content roots, so the local site.yml is consulted
32
+ * rather than assuming the defaults.
33
+ */
34
+ export function siteContentRoots(siteDir) {
35
+ const roots = new Set(['site.yml', 'theme.yml', 'head.html', 'collections.yml', 'locales'])
36
+ let paths = {}
37
+ try {
38
+ paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
39
+ } catch { /* no or unreadable site.yml — the defaults are right */ }
40
+ roots.add(paths.pages || 'pages')
41
+ roots.add(paths.layout || 'layout')
42
+ roots.add(paths.collections || 'collections')
43
+ return [...roots]
44
+ }
45
+
46
+ /** Is there uncommitted work where a pull would write? False outside a repo. */
47
+ export function hasUncommittedContent(siteDir) {
48
+ const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
49
+ return Array.isArray(dirty) && dirty.length > 0
50
+ }
51
+
52
+ function git(args, cwd) {
53
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
54
+ }
55
+
56
+ /**
57
+ * Is `dir` inside a git work tree? False when git is absent, when the path isn't
58
+ * tracked, or on any error — the caller treats all three the same way.
59
+ */
60
+ export function isGitRepo(dir) {
61
+ try {
62
+ return git(['rev-parse', '--is-inside-work-tree'], dir).trim() === 'true'
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Uncommitted work under `relPaths`, relative to `dir`.
70
+ *
71
+ * Counts modified, staged, deleted AND untracked files. Untracked matters as much
72
+ * as modified here: a section file that exists only locally is not on the backend,
73
+ * so a pruning pull deletes it — losing work that was never committed anywhere.
74
+ *
75
+ * @returns {string[]|null} repo-relative-ish paths as git reports them, or `null`
76
+ * when this isn't a git work tree (distinct from `[]`, which means "clean").
77
+ */
78
+ export function uncommittedUnder(dir, relPaths) {
79
+ if (!isGitRepo(dir)) return null
80
+ try {
81
+ const out = git(['status', '--porcelain', '--untracked-files=all', '--', ...relPaths], dir)
82
+ return out
83
+ .split('\n')
84
+ .filter(Boolean)
85
+ // porcelain v1: XY<space>path, and a rename is "orig -> new".
86
+ .map((line) => line.slice(3).trim())
87
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
88
+ .filter(Boolean)
89
+ } catch {
90
+ // A path git doesn't know (e.g. none of the roots exist yet) is not an error
91
+ // worth failing a pull over.
92
+ return []
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A file's committed content — the COMMON ANCESTOR for a three-way merge.
98
+ *
99
+ * This is the input a merge needs and the backend cannot supply: it keeps no
100
+ * per-version item content, and retaining some so it could feed a merge running on
101
+ * the client would put a permanent second write on its hottest table. It doesn't
102
+ * have to. `uniweb pull` writes the backend's content into these files, so the
103
+ * committed version IS the state both sides diverged from — the ancestor was always
104
+ * on this side, in the tool that already knows how to merge.
105
+ *
106
+ * @returns {Buffer|null} null when the path isn't in HEAD (a file added locally and
107
+ * never committed has no ancestor, so there is nothing to merge against).
108
+ */
109
+ export function showAtHead(dir, relPath) {
110
+ try {
111
+ return execFileSync('git', ['show', `HEAD:${relPath}`], {
112
+ cwd: dir,
113
+ stdio: ['ignore', 'pipe', 'ignore'],
114
+ maxBuffer: 64 * 1024 * 1024,
115
+ })
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Three-way merge, in place: `minePath` is rewritten with the merged result.
123
+ *
124
+ * Delegates to `git merge-file`, which is the same machinery git uses for a merge —
125
+ * text that only one side touched is taken silently, and only genuine overlaps get
126
+ * conflict markers. Building our own would be a worse version of a solved problem.
127
+ *
128
+ * @returns {{ merged: boolean, conflicted: boolean }} `merged:false` means the merge
129
+ * could not be attempted at all, which is different from attempting it and finding
130
+ * conflicts — the caller must not conflate them.
131
+ */
132
+ export function mergeFile(dir, minePath, basePath, theirsPath, labels = {}) {
133
+ try {
134
+ execFileSync(
135
+ 'git',
136
+ [
137
+ 'merge-file',
138
+ '-L', labels.mine || 'yours (local)',
139
+ '-L', labels.base || 'common ancestor',
140
+ '-L', labels.theirs || 'theirs (backend)',
141
+ minePath, basePath, theirsPath,
142
+ ],
143
+ { cwd: dir, stdio: ['ignore', 'ignore', 'ignore'] }
144
+ )
145
+ return { merged: true, conflicted: false }
146
+ } catch (err) {
147
+ // A positive status is the CONFLICT COUNT and the file still holds a valid
148
+ // merge with markers. Anything else (git missing, unreadable input) means no
149
+ // merge happened and the caller must fall back rather than trust the file.
150
+ if (typeof err?.status === 'number' && err.status > 0 && err.status < 128) {
151
+ return { merged: true, conflicted: true }
152
+ }
153
+ return { merged: false, conflicted: false }
154
+ }
155
+ }
156
+
157
+ /** Does this repo have a remote configured to pull from? */
158
+ export function hasRemote(dir) {
159
+ try {
160
+ return git(['remote'], dir).trim().length > 0
161
+ } catch {
162
+ return false
163
+ }
164
+ }
165
+
166
+ /**
167
+ * `git pull --ff-only` — bring in teammates' commits.
168
+ *
169
+ * Fast-forward only, deliberately. A refresh should never silently create a merge
170
+ * commit or drop the user into a rebase they didn't ask for; if the branches have
171
+ * genuinely diverged, that is a git problem the user should handle in git, with
172
+ * git's own vocabulary. We report and step back.
173
+ *
174
+ * @returns {{ ok: boolean, changed: boolean, message: string }}
175
+ */
176
+ export function pullRemote(dir) {
177
+ const before = (() => { try { return git(['rev-parse', 'HEAD'], dir).trim() } catch { return null } })()
178
+ try {
179
+ const out = execFileSync('git', ['pull', '--ff-only'], {
180
+ cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
181
+ })
182
+ const after = (() => { try { return git(['rev-parse', 'HEAD'], dir).trim() } catch { return null } })()
183
+ return { ok: true, changed: Boolean(before && after && before !== after), message: out.trim() }
184
+ } catch (err) {
185
+ const msg = [err?.stderr, err?.stdout].map((b) => (b ? String(b) : '')).join('\n').trim()
186
+ return { ok: false, changed: false, message: msg || 'git pull failed' }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Provenance for a deploy record: `{ sha, dirty }`, or null outside a repo.
192
+ * Answers "what is actually live?" later, which a version number cannot.
193
+ */
194
+ export function headProvenance(dir) {
195
+ if (!isGitRepo(dir)) return null
196
+ try {
197
+ const sha = git(['rev-parse', 'HEAD'], dir).trim()
198
+ const dirty = git(['status', '--porcelain'], dir).trim().length > 0
199
+ return { sha, dirty }
200
+ } catch {
201
+ return null // a repo with no commits yet
202
+ }
203
+ }