uniweb 0.13.4 → 0.13.6
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.
- package/package.json +7 -7
- package/partials/agents.md +51 -0
- package/src/backend/site-sync.js +326 -14
- package/src/commands/deploy.js +6 -1
- package/src/commands/doctor.js +20 -0
- package/src/commands/publish.js +25 -1
- package/src/commands/pull.js +387 -7
- package/src/commands/push.js +43 -3
- package/src/commands/refresh.js +176 -0
- package/src/commands/sync.js +75 -0
- package/src/framework-index.json +19 -10
- package/src/index.js +16 -0
- package/src/utils/git.js +203 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb refresh — catch up with everything outside your working copy.
|
|
3
|
+
*
|
|
4
|
+
* A developer on a synced site has TWO independent external sources, and nothing
|
|
5
|
+
* in the tool used to connect them:
|
|
6
|
+
*
|
|
7
|
+
* - the git remote — teammates' commits;
|
|
8
|
+
* - the backend — content authors' edits, made in the app.
|
|
9
|
+
*
|
|
10
|
+
* Someone who runs only `git pull` silently misses every app edit; someone who
|
|
11
|
+
* runs only `uniweb pull` misses their teammates. The person most likely to be
|
|
12
|
+
* caught out is exactly the one starting their day believing they are current.
|
|
13
|
+
* This is the one command for that: run it in the morning, or before a milestone.
|
|
14
|
+
*
|
|
15
|
+
* READ-ONLY, and that is the load-bearing decision. It never pushes. Because it
|
|
16
|
+
* cannot ship anything, it can be run reflexively without weighing consequences —
|
|
17
|
+
* which is the whole point of a start-of-day command. A version that also pushed
|
|
18
|
+
* would be one you had to think about first, and so one people would stop running.
|
|
19
|
+
*
|
|
20
|
+
* Order is deliberate: git first, then the backend. The three-way merge's common
|
|
21
|
+
* ancestor is the COMMITTED version of each file, so taking teammates' commits
|
|
22
|
+
* first makes that ancestor fresher and the merge more accurate. And if git itself
|
|
23
|
+
* conflicts, refresh stops there — one source of conflict at a time.
|
|
24
|
+
*
|
|
25
|
+
* Exits NON-ZERO when a merge left conflicts, the way a conflicted `git merge`
|
|
26
|
+
* does. That is what makes `uniweb refresh && uniweb push` correct by
|
|
27
|
+
* construction: without it, the obvious one-liner ships conflict markers into live
|
|
28
|
+
* content. If a `sync` verb is ever wanted, it is that chain.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* uniweb refresh git pull, then merge the backend's content
|
|
32
|
+
* uniweb refresh --no-git skip the git remote; backend only
|
|
33
|
+
* uniweb refresh --no-backend skip the backend; git only
|
|
34
|
+
* uniweb refresh --backend <url> Override the backend origin
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readFileSync } from 'node:fs'
|
|
38
|
+
import { join } from 'node:path'
|
|
39
|
+
import yaml from 'js-yaml'
|
|
40
|
+
|
|
41
|
+
import { resolveSiteDir } from './deploy.js'
|
|
42
|
+
import { isGitRepo, hasRemote, pullRemote, headProvenance } from '../utils/git.js'
|
|
43
|
+
import { probeUnpushed } from '../backend/site-sync.js'
|
|
44
|
+
|
|
45
|
+
const c = {
|
|
46
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
47
|
+
cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
|
|
48
|
+
}
|
|
49
|
+
const say = {
|
|
50
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
51
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
52
|
+
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
53
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
54
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Forward a flag AND its value to the delegated verb. `--backend http://x` is two
|
|
58
|
+
// argv entries, so a naive filter passes the flag and drops the URL — leaving the
|
|
59
|
+
// delegated pull pointed at the default backend while the user believes they
|
|
60
|
+
// overrode it. `--backend=http://x` is one entry and passes through as-is.
|
|
61
|
+
function collectPassthrough(args, names) {
|
|
62
|
+
const out = []
|
|
63
|
+
for (let i = 0; i < args.length; i++) {
|
|
64
|
+
const a = args[i]
|
|
65
|
+
const name = names.find((n) => a === n || a.startsWith(`${n}=`))
|
|
66
|
+
if (!name) continue
|
|
67
|
+
out.push(a)
|
|
68
|
+
if (a === name && args[i + 1] && !args[i + 1].startsWith('-')) out.push(args[++i])
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A site is backend-synced once it has an identity to pull by.
|
|
74
|
+
function siteContentUuid(siteDir) {
|
|
75
|
+
try {
|
|
76
|
+
const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
|
|
77
|
+
return typeof y?.$uuid === 'string' ? y.$uuid : null
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string[]} args
|
|
85
|
+
* @param {object} [deps] - injectable seams for testing, mirroring pull.js:
|
|
86
|
+
* `resolveSiteDir`, and `pull` (so the backend step can be driven without a
|
|
87
|
+
* network or a real backend).
|
|
88
|
+
*/
|
|
89
|
+
export async function refresh(args = [], deps = {}) {
|
|
90
|
+
const skipGit = args.includes('--no-git')
|
|
91
|
+
const skipBackend = args.includes('--no-backend')
|
|
92
|
+
const resolveSite = deps.resolveSiteDir || resolveSiteDir
|
|
93
|
+
const siteDir = await resolveSite(args, 'refresh')
|
|
94
|
+
|
|
95
|
+
// Which sources this run actually consulted. Reported at the end, because
|
|
96
|
+
// "up to date" is a different claim from "up to date with the two things I
|
|
97
|
+
// happened to be able to check" — and the second is all we can ever honestly
|
|
98
|
+
// say. Silently skipping a source is how someone concludes they are current
|
|
99
|
+
// when they are not.
|
|
100
|
+
const consulted = []
|
|
101
|
+
const skipped = []
|
|
102
|
+
let conflicts = 0
|
|
103
|
+
|
|
104
|
+
// ── 1. the git remote ─────────────────────────────────────────────────────
|
|
105
|
+
if (skipGit) {
|
|
106
|
+
skipped.push('git (--no-git)')
|
|
107
|
+
} else if (!isGitRepo(siteDir)) {
|
|
108
|
+
skipped.push('git (not a repository)')
|
|
109
|
+
} else if (!hasRemote(siteDir)) {
|
|
110
|
+
skipped.push('git (no remote configured)')
|
|
111
|
+
} else {
|
|
112
|
+
say.info('Pulling from the git remote…')
|
|
113
|
+
const r = pullRemote(siteDir)
|
|
114
|
+
if (!r.ok) {
|
|
115
|
+
say.err('git pull failed — resolve this before continuing.')
|
|
116
|
+
for (const line of r.message.split('\n').slice(0, 6)) say.dim(line)
|
|
117
|
+
// Deliberately stop. Layering the backend's content on top of an unresolved
|
|
118
|
+
// git state gives the user two independent conflicts at once, and no clear
|
|
119
|
+
// order to address them in.
|
|
120
|
+
return { exitCode: 1 }
|
|
121
|
+
}
|
|
122
|
+
say.dim(r.changed ? 'Took new commits from the remote.' : 'Already up to date with the remote.')
|
|
123
|
+
consulted.push('git')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 2. the backend ────────────────────────────────────────────────────────
|
|
127
|
+
if (skipBackend) {
|
|
128
|
+
skipped.push('backend (--no-backend)')
|
|
129
|
+
} else if (!siteContentUuid(siteDir)) {
|
|
130
|
+
skipped.push('backend (this site has never been synced)')
|
|
131
|
+
} else {
|
|
132
|
+
say.info("Merging the backend's content…")
|
|
133
|
+
const pull = deps.pull || (await import('./pull.js')).pull
|
|
134
|
+
// `--merge` rather than a plain pull: an author editing a different part of the
|
|
135
|
+
// same section is not a conflict, and should not be presented as one.
|
|
136
|
+
const passthrough = collectPassthrough(args, ['--backend', '--registry', '--token'])
|
|
137
|
+
const res = await pull(['--merge', ...passthrough])
|
|
138
|
+
conflicts = res?.merge?.conflicted?.length ?? 0
|
|
139
|
+
if (res?.exitCode && !conflicts) {
|
|
140
|
+
// Failed for a reason other than conflicts — say so plainly rather than
|
|
141
|
+
// reporting a clean refresh over a lane that never ran.
|
|
142
|
+
say.err('Could not merge the backend content.')
|
|
143
|
+
return { exitCode: res.exitCode }
|
|
144
|
+
}
|
|
145
|
+
consulted.push('backend')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── 3. where that leaves you ──────────────────────────────────────────────
|
|
149
|
+
console.log('')
|
|
150
|
+
if (consulted.length) say.dim(`Checked: ${consulted.join(', ')}`)
|
|
151
|
+
for (const s of skipped) say.dim(`Skipped: ${s}`)
|
|
152
|
+
|
|
153
|
+
const git = headProvenance(siteDir)
|
|
154
|
+
if (git) say.dim(`Commit : ${git.sha.slice(0, 8)}${git.dirty ? ' (working tree has changes)' : ''}`)
|
|
155
|
+
|
|
156
|
+
// What is still yours to send. The point of a milestone check is knowing both
|
|
157
|
+
// directions, not just that you took what was waiting.
|
|
158
|
+
if (!skipBackend && siteContentUuid(siteDir)) {
|
|
159
|
+
try {
|
|
160
|
+
const probe = await probeUnpushed(siteDir)
|
|
161
|
+
if (probe.changed) say.dim(`Unpushed: ${probe.changed} entit${probe.changed === 1 ? 'y' : 'ies'} changed locally`)
|
|
162
|
+
} catch {
|
|
163
|
+
/* the probe is a courtesy; never fail a refresh over it */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log('')
|
|
168
|
+
if (conflicts) {
|
|
169
|
+
say.err(`${conflicts} file(s) need you to resolve conflicts before pushing.`)
|
|
170
|
+
return { exitCode: 1 }
|
|
171
|
+
}
|
|
172
|
+
say.ok('Up to date.')
|
|
173
|
+
return { exitCode: 0 }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default refresh
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb sync — catch up, then share. `refresh` followed by `push`.
|
|
3
|
+
*
|
|
4
|
+
* The two halves already exist and already carry the safety:
|
|
5
|
+
*
|
|
6
|
+
* - `refresh` takes teammates' commits and merges the backend's content, and
|
|
7
|
+
* exits NON-ZERO when a merge left conflicts;
|
|
8
|
+
* - `push` refuses when the backend has moved under you, and never overwrites
|
|
9
|
+
* an author's work blind.
|
|
10
|
+
*
|
|
11
|
+
* So this verb adds no guarantees of its own — it composes two commands that
|
|
12
|
+
* already hold them. That is deliberate: a safety check you can only get by
|
|
13
|
+
* remembering to type `sync` is not a safety check, which is why the gate lives in
|
|
14
|
+
* `push` and the conflict stop lives in `refresh`. Anyone typing
|
|
15
|
+
* `uniweb refresh && uniweb push` gets the identical behaviour, and the exit codes
|
|
16
|
+
* are what make that chain correct rather than anything here.
|
|
17
|
+
*
|
|
18
|
+
* A THIN COMPOSITION, not a reimplementation. Both halves are called, never
|
|
19
|
+
* re-created. The `deploy`/`publish` split is the cautionary tale: two paths that
|
|
20
|
+
* did nearly the same thing drifted until one of them was quietly wrong.
|
|
21
|
+
*
|
|
22
|
+
* WHY IT STOPS ON CONFLICTS. Pushing after an unresolved merge would put conflict
|
|
23
|
+
* markers into content authors see. `refresh` reports them and exits non-zero; this
|
|
24
|
+
* stops there and says what is left to do.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT DOES NOT DO: publish. Sync brings the local copy and the backend DRAFT
|
|
27
|
+
* into agreement; going live stays a separate, deliberate act (`uniweb publish`).
|
|
28
|
+
* That separation is what keeps an unintended sync recoverable — the worst case is
|
|
29
|
+
* work-in-progress visible to authors in the app, not shipped to visitors.
|
|
30
|
+
*
|
|
31
|
+
* Reach for `refresh` when you only want to catch up — it cannot ship anything, so
|
|
32
|
+
* it is the one to run reflexively. Reach for `sync` when you also mean to share.
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* uniweb sync refresh, then push
|
|
36
|
+
* uniweb sync --no-git skip the git remote half of the refresh
|
|
37
|
+
* uniweb sync --force forwarded to push (overwrite upstream changes)
|
|
38
|
+
* uniweb sync --backend <url> override the backend origin
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const c = { reset: '\x1b[0m', dim: '\x1b[2m', cyan: '\x1b[36m', red: '\x1b[31m' }
|
|
42
|
+
const say = {
|
|
43
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
44
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
45
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string[]} args
|
|
50
|
+
* @param {object} [deps] - injectable seams for testing: `refresh`, `push`.
|
|
51
|
+
*/
|
|
52
|
+
export async function sync(args = [], deps = {}) {
|
|
53
|
+
const refresh = deps.refresh || (await import('./refresh.js')).refresh
|
|
54
|
+
const push = deps.push || (await import('./push.js')).push
|
|
55
|
+
|
|
56
|
+
// `--force` means "overwrite upstream" and belongs to the push half only. Passing
|
|
57
|
+
// it to the refresh would ask pull to DISCARD the local work this command exists
|
|
58
|
+
// to send — the same word meaning opposite things on the two halves.
|
|
59
|
+
const refreshArgs = args.filter((a) => a !== '--force')
|
|
60
|
+
|
|
61
|
+
const r = await refresh(refreshArgs)
|
|
62
|
+
if (r?.exitCode) {
|
|
63
|
+
// refresh already explained itself — conflicts, or a git failure. Adding a
|
|
64
|
+
// second summary here would just bury it.
|
|
65
|
+
say.dim('Not pushing while that is unresolved.')
|
|
66
|
+
return { exitCode: r.exitCode }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log('')
|
|
70
|
+
say.info('Pushing your changes…')
|
|
71
|
+
const p = await push(args)
|
|
72
|
+
return { exitCode: p?.exitCode ?? 0 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default sync
|
package/src/framework-index.json
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-26T22:08:46.287Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.15.
|
|
6
|
+
"version": "0.15.6",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
10
10
|
"@uniweb/content-writer",
|
|
11
11
|
"@uniweb/core",
|
|
12
|
+
"@uniweb/projections",
|
|
12
13
|
"@uniweb/runtime",
|
|
13
14
|
"@uniweb/schemas",
|
|
14
15
|
"@uniweb/theming"
|
|
15
16
|
]
|
|
16
17
|
},
|
|
17
18
|
"@uniweb/content-reader": {
|
|
18
|
-
"version": "1.1.
|
|
19
|
+
"version": "1.1.16",
|
|
19
20
|
"path": "framework/content-reader",
|
|
20
21
|
"deps": []
|
|
21
22
|
},
|
|
22
23
|
"@uniweb/content-writer": {
|
|
23
|
-
"version": "0.2.
|
|
24
|
+
"version": "0.2.8",
|
|
24
25
|
"path": "framework/content-writer",
|
|
25
26
|
"deps": []
|
|
26
27
|
},
|
|
27
28
|
"@uniweb/core": {
|
|
28
|
-
"version": "0.7.
|
|
29
|
+
"version": "0.7.30",
|
|
29
30
|
"path": "framework/core",
|
|
30
31
|
"deps": [
|
|
31
32
|
"@uniweb/semantic-parser",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"deps": []
|
|
44
45
|
},
|
|
45
46
|
"@uniweb/kit": {
|
|
46
|
-
"version": "0.9.
|
|
47
|
+
"version": "0.9.38",
|
|
47
48
|
"path": "framework/kit",
|
|
48
49
|
"deps": [
|
|
49
50
|
"@uniweb/core",
|
|
@@ -60,8 +61,16 @@
|
|
|
60
61
|
"path": "framework/press",
|
|
61
62
|
"deps": []
|
|
62
63
|
},
|
|
64
|
+
"@uniweb/projections": {
|
|
65
|
+
"version": "0.1.1",
|
|
66
|
+
"path": "framework/projections",
|
|
67
|
+
"deps": [
|
|
68
|
+
"@uniweb/content-writer",
|
|
69
|
+
"@uniweb/core"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
63
72
|
"@uniweb/runtime": {
|
|
64
|
-
"version": "0.8.
|
|
73
|
+
"version": "0.8.37",
|
|
65
74
|
"path": "framework/runtime",
|
|
66
75
|
"deps": [
|
|
67
76
|
"@uniweb/core",
|
|
@@ -84,7 +93,7 @@
|
|
|
84
93
|
"deps": []
|
|
85
94
|
},
|
|
86
95
|
"@uniweb/semantic-parser": {
|
|
87
|
-
"version": "1.1.
|
|
96
|
+
"version": "1.1.19",
|
|
88
97
|
"path": "framework/semantic-parser",
|
|
89
98
|
"deps": []
|
|
90
99
|
},
|
|
@@ -94,12 +103,12 @@
|
|
|
94
103
|
"deps": []
|
|
95
104
|
},
|
|
96
105
|
"@uniweb/theming": {
|
|
97
|
-
"version": "0.1.
|
|
106
|
+
"version": "0.1.14",
|
|
98
107
|
"path": "framework/theming",
|
|
99
108
|
"deps": []
|
|
100
109
|
},
|
|
101
110
|
"@uniweb/unipress": {
|
|
102
|
-
"version": "0.5.
|
|
111
|
+
"version": "0.5.5",
|
|
103
112
|
"path": "framework/unipress",
|
|
104
113
|
"deps": [
|
|
105
114
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -622,6 +622,20 @@ async function main() {
|
|
|
622
622
|
process.exit(result?.exitCode ?? 0)
|
|
623
623
|
}
|
|
624
624
|
|
|
625
|
+
// Handle sync command (refresh + push; both halves imported, never reimplemented)
|
|
626
|
+
if (command === 'sync') {
|
|
627
|
+
const { sync } = await importProjectCommand('./commands/sync.js')
|
|
628
|
+
const result = await sync(args.slice(1))
|
|
629
|
+
process.exit(result?.exitCode ?? 0)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Handle refresh command (dynamic import — depends on @uniweb/build via pull)
|
|
633
|
+
if (command === 'refresh') {
|
|
634
|
+
const { refresh } = await importProjectCommand('./commands/refresh.js')
|
|
635
|
+
const result = await refresh(args.slice(1))
|
|
636
|
+
process.exit(result?.exitCode ?? 0)
|
|
637
|
+
}
|
|
638
|
+
|
|
625
639
|
// Handle status command (dynamic import — offline emit via @uniweb/build)
|
|
626
640
|
if (command === 'status') {
|
|
627
641
|
const { status } = await importProjectCommand('./commands/status.js')
|
|
@@ -1490,6 +1504,8 @@ ${colors.bright}Commands:${colors.reset}
|
|
|
1490
1504
|
runtime register Register an @uniweb/runtime version to the backend (@std only)
|
|
1491
1505
|
push Push a site's content to the backend
|
|
1492
1506
|
pull Pull a site's content from the backend
|
|
1507
|
+
refresh Catch up with the git remote AND the backend (never pushes)
|
|
1508
|
+
sync Catch up, then push (refresh + push)
|
|
1493
1509
|
status Show a site's sync state (unpushed content, foundation)
|
|
1494
1510
|
inspect <path> Inspect parsed content shape of a markdown file or folder
|
|
1495
1511
|
docs Generate component documentation
|
package/src/utils/git.js
ADDED
|
@@ -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
|
+
}
|