sync-header-metadata 2.3.0

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/src/sync.js ADDED
@@ -0,0 +1,265 @@
1
+ // vim:set expandtab shiftwidth=4 filetype=javascript:
2
+ // SPDX-License-Identifier: GPL-3.0-only
3
+
4
+ //
5
+ //
6
+ // ~chewygumxx/sync-header-metadata.git
7
+ // ::: :/src/sync.js
8
+ //
9
+ //
10
+
11
+ "use strict";
12
+
13
+ const fs = require("node:fs");
14
+ const path = require("node:path");
15
+ const { execFileSync } = require("node:child_process");
16
+
17
+ // A file opts out of header syncing by explicitly unsetting the
18
+ // 'sync-header-metadata' boolean attribute in .gitattributes, e.g.:
19
+ // /LICENSE -sync-header-metadata
20
+ //
21
+ // default.gitattributes ships a baseline exclusion list (LICENSE, lockfiles,
22
+ // *.json, etc.) via `core.attributesFile`, which git consults only as a
23
+ // last resort. Any matching line in the repo's own .gitattributes/
24
+ // info/attributes always takes precedence over it.
25
+ const ATTR = "sync-header-metadata";
26
+ const DEFAULT_ATTRIBUTES_FILE = path.join(
27
+ __dirname,
28
+ "..",
29
+ "default.gitattributes",
30
+ );
31
+
32
+ // ------------------------------
33
+ // Helpers: Resolve Header Lines
34
+ // ------------------------------
35
+
36
+ function findRepoMarker(lines) {
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const m = REPO_MARKER_RE.exec(lines[i]);
39
+ if (m)
40
+ return {
41
+ index: i,
42
+ splitAt: m.index,
43
+ current: m[1],
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const REPO_MARKER_RE = /~(\S+\/\S+?)\.git\s*$/;
50
+ const PATH_MARKER_RE = / ::: :(\/\S*)\s*$/;
51
+ function findPathMarker(lines) {
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const m = PATH_MARKER_RE.exec(lines[i]);
54
+ if (m)
55
+ return {
56
+ index: i,
57
+ splitAt: m.index + m[0].indexOf(m[1]),
58
+ current: m[1],
59
+ };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ // Shared by the Action (run.js) and the CLI (bin/sync-header-metadata.js);
65
+ // each entry point resolves its own inputs and exits with the returned code.
66
+ function sync({ mode, repository, log, cwd = process.cwd() }) {
67
+ const verify = mode === "verify";
68
+
69
+ // ------------------------
70
+ // Helper: Resolve Tracked
71
+ // ------------------------
72
+
73
+ try {
74
+ execFileSync("git", ["--version"], { stdio: "ignore" });
75
+ } catch {
76
+ log.fatal("Dependency not found in PATH: git", 127);
77
+ }
78
+
79
+ let repoRoot;
80
+ try {
81
+ repoRoot = execFileSync(
82
+ "git",
83
+ ["-C", cwd, "rev-parse", "--show-toplevel"],
84
+ { encoding: "utf8" },
85
+ ).trim();
86
+ } catch {
87
+ log.fatal("Not inside a git repository");
88
+ }
89
+
90
+ let fileListRaw;
91
+ try {
92
+ fileListRaw = execFileSync("git", ["-C", repoRoot, "ls-files", "-z"], {
93
+ encoding: "utf8",
94
+ });
95
+ } catch (err) {
96
+ log.fatal(`Failed to list tracked files: ${err.message}`);
97
+ }
98
+ const allFiles = fileListRaw.split("\0").filter(Boolean);
99
+
100
+ let checkAttrRaw = "";
101
+ if (allFiles.length > 0) {
102
+ checkAttrRaw = execFileSync(
103
+ "git",
104
+ [
105
+ "-c",
106
+ `core.attributesFile=${DEFAULT_ATTRIBUTES_FILE}`,
107
+ "-C",
108
+ repoRoot,
109
+ "check-attr",
110
+ "-z",
111
+ "--stdin",
112
+ ATTR,
113
+ ],
114
+ { input: allFiles.join("\0"), encoding: "utf8" },
115
+ );
116
+ }
117
+ const attrParts = checkAttrRaw.split("\0");
118
+ attrParts.pop();
119
+ const ignored = new Set();
120
+ for (let i = 0; i < attrParts.length; i += 3) {
121
+ if (attrParts[i + 2] === "unset") ignored.add(attrParts[i]);
122
+ }
123
+ const files = allFiles.filter((f) => !ignored.has(f));
124
+
125
+ if (files.length === 0) {
126
+ log.warn({
127
+ title: "Nothing Found",
128
+ message: "No tracked files found in repository",
129
+ });
130
+ return 0;
131
+ }
132
+
133
+ // ------------
134
+ // Parse Files
135
+ // ------------
136
+
137
+ let parsed = 0,
138
+ unreadable = 0;
139
+ let repoUpdated = 0,
140
+ repoCorrect = 0,
141
+ repoNotFound = 0;
142
+ let pathUpdated = 0,
143
+ pathCorrect = 0,
144
+ pathNotFound = 0;
145
+
146
+ for (const relpath of files) {
147
+ const filePath = `${repoRoot}/${relpath}`;
148
+ const repoPath = `/${relpath}`;
149
+
150
+ // Read
151
+ let content;
152
+ try {
153
+ content = fs.readFileSync(filePath, "utf8");
154
+ parsed++;
155
+ } catch {
156
+ log.warn({
157
+ file: relpath,
158
+ title: "Failed to read file as utf8 encoded",
159
+ message: `Consider ignoring with .gitattributes: \`${repoPath} -${ATTR}\` `,
160
+ });
161
+ unreadable++;
162
+ continue;
163
+ }
164
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
165
+ const lines = content.split(/\r\n|\n/);
166
+
167
+ // Repository
168
+ let changed = false;
169
+ const repoMarker = findRepoMarker(lines);
170
+ if (!repoMarker) {
171
+ log.warn({
172
+ file: relpath,
173
+ title: "Repo line not found",
174
+ message: "",
175
+ });
176
+ repoNotFound++;
177
+ } else if (repoMarker.current === repository) {
178
+ log.info(`Repo line correct: ${repoPath}`);
179
+ repoCorrect++;
180
+ } else if (verify) {
181
+ log.error({
182
+ file: relpath,
183
+ title: "Repo line out-of-sync",
184
+ message: `${repoMarker.current} =/= ${repository}`,
185
+ startLine: repoMarker.index + 1,
186
+ });
187
+ repoUpdated++;
188
+ } else {
189
+ const leader = lines[repoMarker.index].slice(0, repoMarker.splitAt);
190
+ lines[repoMarker.index] = `${leader}~${repository}.git`;
191
+ changed = true;
192
+ log.notice({
193
+ file: relpath,
194
+ title: "Repo line updated",
195
+ message: `${repoMarker.current} -> ${repository}`,
196
+ startLine: repoMarker.index + 1,
197
+ });
198
+ repoUpdated++;
199
+ }
200
+
201
+ // Filepath
202
+ const pathMarker = findPathMarker(lines);
203
+ if (!pathMarker) {
204
+ log.warn({
205
+ file: relpath,
206
+ title: "Path line not found",
207
+ message: "",
208
+ });
209
+ pathNotFound++;
210
+ } else if (pathMarker.current === repoPath) {
211
+ log.info(`Path line correct: ${repoPath}`);
212
+ pathCorrect++;
213
+ } else if (verify) {
214
+ log.error({
215
+ file: relpath,
216
+ title: "Path line out-of-sync",
217
+ message: `${pathMarker.current} =/= ${repoPath}`,
218
+ startLine: pathMarker.index + 1,
219
+ });
220
+ pathUpdated++;
221
+ } else {
222
+ const leader = lines[pathMarker.index].slice(0, pathMarker.splitAt);
223
+ lines[pathMarker.index] = `${leader}${repoPath}`;
224
+ changed = true;
225
+ log.notice({
226
+ file: relpath,
227
+ title: "Path line updated",
228
+ message: `${pathMarker.current} -> ${repoPath}`,
229
+ startLine: pathMarker.index + 1,
230
+ });
231
+ pathUpdated++;
232
+ }
233
+
234
+ // Write
235
+ if (changed) {
236
+ fs.writeFileSync(filePath, lines.join(eol));
237
+ }
238
+ }
239
+
240
+ // ---------------
241
+ // Post-Execution
242
+ // ---------------
243
+
244
+ const summary =
245
+ ` Files parsed: ${parsed}\n` +
246
+ ` Repo line - ${verify ? "Out-of-Sync" : "Updated"}: ${repoUpdated}, Correct: ${repoCorrect}, Not found: ${repoNotFound}\n` +
247
+ ` Path line - ${verify ? "Out-of-Sync" : "Updated"}: ${pathUpdated}, Correct: ${pathCorrect}, Not found: ${pathNotFound}\n` +
248
+ ` Files unreadable: ${unreadable}`;
249
+
250
+ let sumTitle;
251
+ if (verify) {
252
+ if (repoUpdated > 0 || pathUpdated > 0) {
253
+ sumTitle = "Verification Failed";
254
+ log.error({ title: sumTitle, message: `${sumTitle}:\n${summary}` });
255
+ return 1;
256
+ }
257
+ sumTitle = "Verification Passed";
258
+ } else {
259
+ sumTitle = "Update Complete";
260
+ }
261
+ log.notice({ title: sumTitle, message: `${sumTitle}:\n${summary}` });
262
+ return 0;
263
+ }
264
+
265
+ module.exports = { sync };