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.
@@ -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
@@ -1,31 +1,32 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-25T17:53:51.666Z",
3
+ "generatedAt": "2026-07-26T22:08:46.287Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.15.4",
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.15",
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.7",
24
+ "version": "0.2.8",
24
25
  "path": "framework/content-writer",
25
26
  "deps": []
26
27
  },
27
28
  "@uniweb/core": {
28
- "version": "0.7.28",
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.36",
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.35",
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.18",
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.13",
106
+ "version": "0.1.14",
98
107
  "path": "framework/theming",
99
108
  "deps": []
100
109
  },
101
110
  "@uniweb/unipress": {
102
- "version": "0.5.3",
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
@@ -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
+ }