@stonepandastudio/cairn 0.5.0 → 0.6.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/README.md +38 -12
- package/bin/cairn.js +8 -5
- package/lib/doctor/index.js +6 -1
- package/lib/init.js +5 -1
- package/lib/manifest.js +33 -0
- package/lib/render/cli.js +11 -1
- package/lib/render/index.js +17 -7
- package/lib/sync/cli.js +197 -0
- package/lib/sync/index.js +249 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,11 +24,32 @@ npx cairn init --stack nestjs,typeorm --tracker jira-server --project-key MYPROJ
|
|
|
24
24
|
| `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
|
|
25
25
|
| `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
|
|
26
26
|
| `cairn render` | Scaffolds `ai/` + `.claude/commands/` from the bundled presets, from the repo's stack + workflow |
|
|
27
|
+
| `cairn sync` | Re-renders and three-way merges preset changes into a repo that has already rendered |
|
|
27
28
|
|
|
28
29
|
`cairn render` is **greenfield only**: it writes files that do not exist, records them
|
|
29
|
-
in the manifest, and leaves anything already on
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
in the manifest, stores the render in `_cairn/base/`, and leaves anything already on
|
|
31
|
+
disk alone (reporting it).
|
|
32
|
+
|
|
33
|
+
`cairn sync` is the upgrade path. For each managed file it holds three versions —
|
|
34
|
+
`base` (the last render, in `_cairn/base/`), `ours` (what is on disk), `theirs` (what
|
|
35
|
+
the current presets render) — and runs a 2×2:
|
|
36
|
+
|
|
37
|
+
| | template unchanged | template changed |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| **local unchanged** | no-op | fast-forward — write the new render |
|
|
40
|
+
| **local changed** | keep local (`MODIFIED`) | three-way merge |
|
|
41
|
+
|
|
42
|
+
Three cells are automatic. The merge shells out to `git merge-file`; a clean result
|
|
43
|
+
is written, a conflicted one is written **with markers** and the command exits
|
|
44
|
+
non-zero (`CONFLICT`, a fifth manifest state). Nothing is ever silently overwritten.
|
|
45
|
+
Resolve the markers and run `cairn sync` again — the base has already advanced, so
|
|
46
|
+
the second run is a quiet `MODIFIED`.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cairn sync --dry-run # show the outcome per file, write nothing
|
|
50
|
+
cairn sync # apply; exit 3 if any file is left in conflict
|
|
51
|
+
cairn sync --adopt-base # for files rendered before _cairn/base/ existed
|
|
52
|
+
```
|
|
32
53
|
|
|
33
54
|
## The two folders
|
|
34
55
|
|
|
@@ -37,6 +58,7 @@ repo/
|
|
|
37
58
|
cairn.config.json you edit this. cairn only ever reads it.
|
|
38
59
|
_cairn/ cairn writes this. nobody hand-edits it.
|
|
39
60
|
manifest.json what was generated, from which template, at which hash
|
|
61
|
+
base/ the last render of every managed file — `cairn sync`'s merge base
|
|
40
62
|
scripts/jira.js vendored tracker shim
|
|
41
63
|
.claude/commands/ rendered from the workflow — one stub per step
|
|
42
64
|
ai/
|
|
@@ -47,9 +69,10 @@ repo/
|
|
|
47
69
|
```
|
|
48
70
|
|
|
49
71
|
`cairn render` seeds the rendered files once and records them in the manifest; it
|
|
50
|
-
never rewrites a file you have edited
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
never rewrites a file you have edited. `cairn sync` is what re-renders later preset
|
|
73
|
+
changes over your edits, three-way merging where both moved. The boundary is the
|
|
74
|
+
whole design: hand-edited config living inside a generated directory is how
|
|
75
|
+
generated directories acquire state nobody dares regenerate.
|
|
53
76
|
|
|
54
77
|
Generated files are **committed**, not gitignored. Claude Code discovers
|
|
55
78
|
`.claude/commands/` and agent docs from disk, and a prompt change should show up in
|
|
@@ -132,12 +155,13 @@ match what is on disk?
|
|
|
132
155
|
|---|---|
|
|
133
156
|
| `MANAGED` | on-disk hash matches what cairn wrote |
|
|
134
157
|
| `MODIFIED` | someone hand-edited a generated file |
|
|
135
|
-
| `OUTDATED` | cairn has newer content for it — needs template hashes
|
|
136
|
-
| `DELETED` | it is gone;
|
|
158
|
+
| `OUTDATED` | cairn has newer content for it — needs template hashes threaded through |
|
|
159
|
+
| `DELETED` | it is gone; `cairn sync` restores it from the current render |
|
|
160
|
+
| `CONFLICT` | `cairn sync` left git merge markers in it — resolve, then sync again |
|
|
137
161
|
|
|
138
|
-
Today the doctor reports `MANAGED`, `MODIFIED` and `
|
|
139
|
-
template hashes threaded through)
|
|
140
|
-
|
|
162
|
+
Today the doctor reports `MANAGED`, `MODIFIED`, `DELETED` and `CONFLICT`. `OUTDATED`
|
|
163
|
+
(needs the template hashes threaded through) is still to come. Acting on drift
|
|
164
|
+
between the render and the working tree is `cairn sync`.
|
|
141
165
|
|
|
142
166
|
A file can be perfectly in sync across repos while being hand-edited away from its
|
|
143
167
|
template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
|
|
@@ -174,6 +198,7 @@ lib/paint.js ANSI + table rendering
|
|
|
174
198
|
lib/tracker/ index (registry), cli, jira-server, youtrack, none
|
|
175
199
|
lib/doctor/ index (analysis + report), scan, normalize, diff
|
|
176
200
|
lib/render/ engine (a small template engine), index (driver), cli
|
|
201
|
+
lib/sync/ index (the 2x2 + git merge-file), cli
|
|
177
202
|
lib/init.js cairn init
|
|
178
203
|
templates/shims/ vendored shim source
|
|
179
204
|
presets/ core/ + framework/ORM/styling presets rendered by `cairn render`
|
|
@@ -181,4 +206,5 @@ schema.json JSON Schema for cairn.config.json
|
|
|
181
206
|
test/run.js dependency-free test runner
|
|
182
207
|
```
|
|
183
208
|
|
|
184
|
-
No runtime dependencies. Node >= 18. `npm test`
|
|
209
|
+
No runtime dependencies (`cairn sync` shells out to `git`). Node >= 18. `npm test`
|
|
210
|
+
runs 74 tests.
|
package/bin/cairn.js
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
// cairn — shared AI workflow scaffolding for Stone Panda repos.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// Surface:
|
|
7
7
|
// cairn tracker <cmd> issue tracker client (replaces the copied ai/scripts/jira.js)
|
|
8
8
|
// cairn doctor cross-repo drift report
|
|
9
9
|
// cairn init make a repo cairn-managed
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// deliberately absent: this release swaps the Jira client under five repos, and
|
|
13
|
-
// mixing that with prompt changes would make any regression unattributable.
|
|
10
|
+
// cairn render scaffold ai/ from the bundled presets (greenfield)
|
|
11
|
+
// cairn sync three-way merge of preset changes into a rendered repo
|
|
14
12
|
|
|
15
13
|
const HELP = `cairn — shared AI workflow scaffolding
|
|
16
14
|
|
|
@@ -20,6 +18,7 @@ Usage: cairn <command> [options]
|
|
|
20
18
|
doctor [options] cross-repo drift report (cairn doctor --help)
|
|
21
19
|
init [options] make a repo cairn-managed (cairn init --help)
|
|
22
20
|
render [options] scaffold ai/ from the bundled presets (cairn render --help)
|
|
21
|
+
sync [options] merge preset changes into an already-rendered repo (cairn sync --help)
|
|
23
22
|
version print version
|
|
24
23
|
|
|
25
24
|
Run \`cairn <command> --help\` for command options.
|
|
@@ -45,6 +44,10 @@ function main() {
|
|
|
45
44
|
process.exitCode = require('../lib/render/cli').main(rest);
|
|
46
45
|
return;
|
|
47
46
|
|
|
47
|
+
case 'sync':
|
|
48
|
+
process.exitCode = require('../lib/sync/cli').main(rest);
|
|
49
|
+
return;
|
|
50
|
+
|
|
48
51
|
case 'version':
|
|
49
52
|
case '--version':
|
|
50
53
|
case '-v':
|
package/lib/doctor/index.js
CHANGED
|
@@ -40,6 +40,7 @@ const MANAGED_COLOR = {
|
|
|
40
40
|
OUTDATED: 'yellow',
|
|
41
41
|
MODIFIED: 'red',
|
|
42
42
|
DELETED: 'magenta',
|
|
43
|
+
CONFLICT: 'red',
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
// ------------------------------------------------------------------ workspace
|
|
@@ -510,7 +511,11 @@ function main(argv = process.argv.slice(2)) {
|
|
|
510
511
|
.flatMap((s) => s.files)
|
|
511
512
|
.some((f) => f.status === 'DRIFT' || f.status === 'MISSING' || f.status === 'STRANDED');
|
|
512
513
|
const broken = managed.some(
|
|
513
|
-
(m) =>
|
|
514
|
+
(m) =>
|
|
515
|
+
m.error ||
|
|
516
|
+
m.files.some(
|
|
517
|
+
(f) => f.state === 'MODIFIED' || f.state === 'DELETED' || f.state === 'CONFLICT',
|
|
518
|
+
),
|
|
514
519
|
);
|
|
515
520
|
if (drifted || broken) return 1;
|
|
516
521
|
}
|
package/lib/init.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
readManifest,
|
|
10
10
|
record,
|
|
11
11
|
writeManifest,
|
|
12
|
+
writeBase,
|
|
12
13
|
manifestPath,
|
|
13
14
|
hashContent,
|
|
14
15
|
} = require('./manifest');
|
|
@@ -210,7 +211,10 @@ function main(argv = process.argv.slice(2)) {
|
|
|
210
211
|
fs.writeFileSync(abs, content, 'utf8');
|
|
211
212
|
// cairn.config.json is hand-editable, so it is deliberately not manifested —
|
|
212
213
|
// tracking it would report every legitimate edit as MODIFIED.
|
|
213
|
-
if (source)
|
|
214
|
+
if (source) {
|
|
215
|
+
record(manifest, rel, source, content);
|
|
216
|
+
writeBase(repoPath, rel, content);
|
|
217
|
+
}
|
|
214
218
|
}
|
|
215
219
|
writeManifest(repoPath, manifest);
|
|
216
220
|
|
package/lib/manifest.js
CHANGED
|
@@ -21,11 +21,37 @@ const GENERATOR = require('../package.json').name;
|
|
|
21
21
|
// MODIFIED on-disk hash !== written hash someone edited it by hand
|
|
22
22
|
// OUTDATED written hash !== current template hash cairn has newer content
|
|
23
23
|
// DELETED file is gone sync will restore it
|
|
24
|
+
// CONFLICT entry.conflict is set `cairn sync` left merge markers
|
|
25
|
+
//
|
|
26
|
+
// `_cairn/base/<relPath>` holds the bytes of the last clean template render for
|
|
27
|
+
// each managed file — the merge base `cairn sync` diffs against. The manifest
|
|
28
|
+
// hash is "what is on disk after the last cairn write"; the base file is "what
|
|
29
|
+
// the template produced". They diverge once a merge folds local edits back in.
|
|
24
30
|
|
|
25
31
|
function manifestPath(repoPath) {
|
|
26
32
|
return path.join(cairnDir(repoPath), MANIFEST_NAME);
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
const BASE_DIR = 'base';
|
|
36
|
+
|
|
37
|
+
function basePath(repoPath, relPath) {
|
|
38
|
+
return path.join(cairnDir(repoPath), BASE_DIR, relPath.replace(/\\/g, '/'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The last clean template render for `relPath`. `cairn render` and `cairn init`
|
|
42
|
+
// write it alongside the real file; `cairn sync` reads it as the merge base and
|
|
43
|
+
// rewrites it on every fast-forward or clean merge.
|
|
44
|
+
function readBase(repoPath, relPath) {
|
|
45
|
+
const file = basePath(repoPath, relPath);
|
|
46
|
+
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeBase(repoPath, relPath, content) {
|
|
50
|
+
const file = basePath(repoPath, relPath);
|
|
51
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
// Normalize before hashing so a CRLF checkout doesn't read as a hand edit. This
|
|
30
56
|
// matters here specifically: these repos are Windows-side with mixed .gitattributes.
|
|
31
57
|
function hashContent(text) {
|
|
@@ -83,6 +109,10 @@ function statusFor(repoPath, manifest, templateHashes = null) {
|
|
|
83
109
|
continue;
|
|
84
110
|
}
|
|
85
111
|
const onDisk = hashContent(fs.readFileSync(abs, 'utf8'));
|
|
112
|
+
if (entry.conflict) {
|
|
113
|
+
results.push({ path: rel, source: entry.source, state: 'CONFLICT' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
86
116
|
if (onDisk !== entry.hash) {
|
|
87
117
|
results.push({ path: rel, source: entry.source, state: 'MODIFIED' });
|
|
88
118
|
continue;
|
|
@@ -109,12 +139,15 @@ module.exports = {
|
|
|
109
139
|
CAIRN_DIR,
|
|
110
140
|
MANIFEST_NAME,
|
|
111
141
|
MANIFEST_VERSION,
|
|
142
|
+
basePath,
|
|
112
143
|
emptyManifest,
|
|
113
144
|
hashContent,
|
|
114
145
|
header,
|
|
115
146
|
manifestPath,
|
|
147
|
+
readBase,
|
|
116
148
|
readManifest,
|
|
117
149
|
record,
|
|
118
150
|
statusFor,
|
|
151
|
+
writeBase,
|
|
119
152
|
writeManifest,
|
|
120
153
|
};
|
package/lib/render/cli.js
CHANGED
|
@@ -4,7 +4,14 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
const { loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
readManifest,
|
|
9
|
+
emptyManifest,
|
|
10
|
+
record,
|
|
11
|
+
writeManifest,
|
|
12
|
+
writeBase,
|
|
13
|
+
manifestPath,
|
|
14
|
+
} = require('../manifest');
|
|
8
15
|
const { makePaint } = require('../paint');
|
|
9
16
|
const { planRender } = require('./index');
|
|
10
17
|
|
|
@@ -63,6 +70,9 @@ function renderInto(repoPath, { dryRun = false, paint } = {}) {
|
|
|
63
70
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
64
71
|
fs.writeFileSync(abs, content, 'utf8');
|
|
65
72
|
record(manifest, rel, source, content);
|
|
73
|
+
// The merge base `cairn sync` will diff against: what the template produced,
|
|
74
|
+
// verbatim, this run.
|
|
75
|
+
writeBase(repoPath, rel, content);
|
|
66
76
|
console.log(` ${paint.green('wrote')} ${rel}`);
|
|
67
77
|
wrote++;
|
|
68
78
|
}
|
package/lib/render/index.js
CHANGED
|
@@ -129,11 +129,11 @@ function codeGuidelines(stack, presetsDir, ctx, resolvePartial) {
|
|
|
129
129
|
return blocks.length ? blocks.join('\n\n---\n\n') + '\n' : null;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
function
|
|
132
|
+
// Every file the presets would produce for this config, as
|
|
133
|
+
// [[rel, content, source, note?]] — before any greenfield or manifest guard.
|
|
134
|
+
// `planRender` filters this for `cairn render`; `cairn sync` diffs each entry
|
|
135
|
+
// against the repo's manifest + `_cairn/base/`.
|
|
136
|
+
function renderTargets({ config, presetsDir }) {
|
|
137
137
|
const ctx = buildContext(config, presetsDir);
|
|
138
138
|
const resolvePartial = makeResolvePartial(ctx.stack, presetsDir);
|
|
139
139
|
|
|
@@ -176,10 +176,20 @@ function planRender({ config, presetsDir, manifest = { files: {} }, repoPath = n
|
|
|
176
176
|
'ai/infrastructure/code-guidelines.md',
|
|
177
177
|
guidelines,
|
|
178
178
|
'presets:<stack>/code-guidelines.md',
|
|
179
|
-
'seed — fork it
|
|
179
|
+
'seed — fork it; `cairn sync` will three-way merge later changes',
|
|
180
180
|
]);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
return { targets, context: ctx };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Returns { planned: [[rel, content, source, note?]], kept: [{ rel, reason }] }.
|
|
187
|
+
// `manifest` + `repoPath` drive the greenfield guard: a file cairn has not
|
|
188
|
+
// written is never touched, and one it wrote but a human has since edited is
|
|
189
|
+
// kept and reported (the re-render that respects local edits is `cairn sync`).
|
|
190
|
+
function planRender({ config, presetsDir, manifest = { files: {} }, repoPath = null }) {
|
|
191
|
+
const { targets, context: ctx } = renderTargets({ config, presetsDir });
|
|
192
|
+
|
|
183
193
|
const planned = [];
|
|
184
194
|
const kept = [];
|
|
185
195
|
for (const [rel, content, source, note] of targets) {
|
|
@@ -207,4 +217,4 @@ function planRender({ config, presetsDir, manifest = { files: {} }, repoPath = n
|
|
|
207
217
|
return { planned, kept, context: ctx };
|
|
208
218
|
}
|
|
209
219
|
|
|
210
|
-
module.exports = { planRender, buildContext, trackerContext, DEFAULT_WORKFLOW };
|
|
220
|
+
module.exports = { planRender, renderTargets, buildContext, trackerContext, DEFAULT_WORKFLOW };
|
package/lib/sync/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
|
|
7
|
+
const {
|
|
8
|
+
readManifest,
|
|
9
|
+
record,
|
|
10
|
+
writeManifest,
|
|
11
|
+
writeBase,
|
|
12
|
+
manifestPath,
|
|
13
|
+
} = require('../manifest');
|
|
14
|
+
const { makePaint } = require('../paint');
|
|
15
|
+
const { planSync, OUTCOME } = require('./index');
|
|
16
|
+
|
|
17
|
+
const PRESETS_DIR = path.join(__dirname, '..', '..', 'presets');
|
|
18
|
+
|
|
19
|
+
const HELP = `cairn sync — propagate preset changes into an already-rendered repo
|
|
20
|
+
|
|
21
|
+
Usage: cairn sync [options]
|
|
22
|
+
|
|
23
|
+
--repo <path> repo to sync (default: the repo cairn.config.json is in)
|
|
24
|
+
--dry-run print the 2x2 outcome per file, write nothing
|
|
25
|
+
--adopt-base for files rendered before cairn tracked a merge base and
|
|
26
|
+
hand-edited since: accept the current file as the base
|
|
27
|
+
(you lose the ability to merge upstream changes to it)
|
|
28
|
+
--no-color disable ANSI colour
|
|
29
|
+
|
|
30
|
+
Runs a three-way merge per managed file: base (what the template rendered last
|
|
31
|
+
time, in _cairn/base/), ours (what is on disk), theirs (what the current presets
|
|
32
|
+
render). Clean results are written; a conflict is written with git markers and
|
|
33
|
+
exits non-zero. Nothing is ever silently overwritten.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const COLOR = {
|
|
37
|
+
[OUTCOME.UNCHANGED]: 'dim',
|
|
38
|
+
[OUTCOME.MODIFIED]: 'yellow',
|
|
39
|
+
[OUTCOME.CREATED]: 'green',
|
|
40
|
+
[OUTCOME.FAST_FORWARD]: 'green',
|
|
41
|
+
[OUTCOME.MERGED]: 'green',
|
|
42
|
+
[OUTCOME.RESTORED]: 'green',
|
|
43
|
+
[OUTCOME.CONFLICT]: 'red',
|
|
44
|
+
[OUTCOME.ORPHANED]: 'magenta',
|
|
45
|
+
[OUTCOME.NO_BASE]: 'red',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const args = { repo: null, dryRun: false, adoptBase: false, color: process.stdout.isTTY };
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i];
|
|
52
|
+
if (a === '--repo') args.repo = argv[++i];
|
|
53
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
54
|
+
else if (a === '--adopt-base') args.adoptBase = true;
|
|
55
|
+
else if (a === '--no-color') args.color = false;
|
|
56
|
+
else if (a === '-h' || a === '--help') args.help = true;
|
|
57
|
+
else {
|
|
58
|
+
console.error(`Unknown argument: ${a}`);
|
|
59
|
+
return { error: 2 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return args;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Apply a plan to disk. Returns the count of files left in conflict.
|
|
66
|
+
function applyPlan(repoPath, manifest, actions, paint) {
|
|
67
|
+
let conflicts = 0;
|
|
68
|
+
let changed = false;
|
|
69
|
+
for (const act of actions) {
|
|
70
|
+
if (act.write !== undefined) {
|
|
71
|
+
const abs = path.join(repoPath, act.rel);
|
|
72
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
73
|
+
fs.writeFileSync(abs, act.write, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
// record() rebuilds the entry, so it drops any stale `conflict` flag; set the
|
|
76
|
+
// fresh one back afterwards.
|
|
77
|
+
if (act.write !== undefined && act.source !== undefined) {
|
|
78
|
+
record(manifest, act.rel, act.source, act.write);
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
if (act.base !== undefined) writeBase(repoPath, act.rel, act.base);
|
|
82
|
+
if (act.conflict) {
|
|
83
|
+
manifest.files[act.rel].conflict = true;
|
|
84
|
+
conflicts++;
|
|
85
|
+
changed = true;
|
|
86
|
+
} else if (act.clearConflict && manifest.files[act.rel] && manifest.files[act.rel].conflict) {
|
|
87
|
+
delete manifest.files[act.rel].conflict;
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
printAction(act, paint);
|
|
91
|
+
}
|
|
92
|
+
return { conflicts, changed };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printAction(act, paint) {
|
|
96
|
+
const tag = paint[COLOR[act.outcome] || 'dim'](act.outcome.padEnd(12));
|
|
97
|
+
const detail = act.detail ? paint.dim(` ${act.detail}`) : '';
|
|
98
|
+
console.log(` ${tag} ${act.rel}${detail}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function main(argv = process.argv.slice(2)) {
|
|
102
|
+
const args = parseArgs(argv);
|
|
103
|
+
if (args.error) return args.error;
|
|
104
|
+
if (args.help) {
|
|
105
|
+
process.stdout.write(HELP);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const paint = makePaint(args.color);
|
|
110
|
+
const start = args.repo ? path.resolve(args.repo) : process.cwd();
|
|
111
|
+
const repoPath = args.repo ? start : findRepoRoot(start);
|
|
112
|
+
if (!repoPath || !fs.existsSync(path.join(repoPath, 'cairn.config.json'))) {
|
|
113
|
+
console.error('No cairn.config.json found — run `cairn init` first');
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = readManifest(repoPath);
|
|
118
|
+
if (!manifest || !Object.keys(manifest.files).length) {
|
|
119
|
+
console.error('Nothing to sync — this repo has no cairn manifest. Run `cairn render` first.');
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let plan;
|
|
124
|
+
try {
|
|
125
|
+
const config = loadRepoConfig(repoPath, { required: true });
|
|
126
|
+
plan = planSync({ config, presetsDir: PRESETS_DIR, manifest, repoPath, adoptBase: args.adoptBase });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (e instanceof ConfigError) {
|
|
129
|
+
console.error(e.message);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (args.dryRun) {
|
|
136
|
+
console.log(paint.bold(`cairn sync --dry-run ${repoPath}`));
|
|
137
|
+
for (const act of plan.actions) printAction(act, paint);
|
|
138
|
+
const conflicts = plan.actions.filter((a) => a.outcome === OUTCOME.CONFLICT).length;
|
|
139
|
+
const noBase = plan.actions.filter((a) => a.outcome === OUTCOME.NO_BASE).length;
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(summary(plan.actions, paint));
|
|
142
|
+
return conflicts || noBase ? 3 : 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(paint.bold(`cairn sync ${repoPath}`));
|
|
146
|
+
const { conflicts, changed } = applyPlan(repoPath, manifest, plan.actions, paint);
|
|
147
|
+
const noBase = plan.actions.filter((a) => a.outcome === OUTCOME.NO_BASE).length;
|
|
148
|
+
|
|
149
|
+
if (changed) {
|
|
150
|
+
writeManifest(repoPath, manifest);
|
|
151
|
+
console.log(
|
|
152
|
+
` ${paint.green('wrote')} ${path
|
|
153
|
+
.relative(repoPath, manifestPath(repoPath))
|
|
154
|
+
.replace(/\\/g, '/')}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(summary(plan.actions, paint));
|
|
160
|
+
|
|
161
|
+
if (conflicts) {
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log(
|
|
164
|
+
paint.red(` ${conflicts} file(s) left with conflict markers`) +
|
|
165
|
+
paint.dim(' — resolve them, then run `cairn sync` again'),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (noBase) {
|
|
169
|
+
console.log(
|
|
170
|
+
paint.red(` ${noBase} file(s) have no merge base`) +
|
|
171
|
+
paint.dim(' — re-run with --adopt-base once you have reviewed them'),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return conflicts || noBase ? 3 : 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function summary(actions, paint) {
|
|
178
|
+
const counts = {};
|
|
179
|
+
for (const a of actions) counts[a.outcome] = (counts[a.outcome] || 0) + 1;
|
|
180
|
+
const order = [
|
|
181
|
+
OUTCOME.CREATED,
|
|
182
|
+
OUTCOME.FAST_FORWARD,
|
|
183
|
+
OUTCOME.MERGED,
|
|
184
|
+
OUTCOME.RESTORED,
|
|
185
|
+
OUTCOME.MODIFIED,
|
|
186
|
+
OUTCOME.UNCHANGED,
|
|
187
|
+
OUTCOME.ORPHANED,
|
|
188
|
+
OUTCOME.NO_BASE,
|
|
189
|
+
OUTCOME.CONFLICT,
|
|
190
|
+
];
|
|
191
|
+
const parts = order
|
|
192
|
+
.filter((k) => counts[k])
|
|
193
|
+
.map((k) => paint[COLOR[k] || 'dim'](`${k} ${counts[k]}`));
|
|
194
|
+
return paint.bold('Summary') + '\n ' + (parts.join(' ') || paint.dim('nothing managed'));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { main, applyPlan, HELP, PRESETS_DIR };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// cairn sync — propagate preset changes into a repo that has already rendered.
|
|
4
|
+
//
|
|
5
|
+
// `cairn render` is greenfield: it writes files that do not exist and never
|
|
6
|
+
// touches one again. `cairn sync` is the upgrade path. For every managed file it
|
|
7
|
+
// holds three versions —
|
|
8
|
+
//
|
|
9
|
+
// base what the template rendered last time (_cairn/base/<rel>)
|
|
10
|
+
// ours what is on disk now (the repo's edits)
|
|
11
|
+
// theirs what the current presets render
|
|
12
|
+
//
|
|
13
|
+
// — and runs a 2x2:
|
|
14
|
+
//
|
|
15
|
+
// template unchanged template changed
|
|
16
|
+
// local unchanged no-op fast-forward (write theirs)
|
|
17
|
+
// local changed keep local (MODIFIED) three-way merge
|
|
18
|
+
//
|
|
19
|
+
// Three cells are automatic. The bottom-right shells out to `git merge-file`;
|
|
20
|
+
// a clean merge is applied, a conflicted one is written with markers and stops
|
|
21
|
+
// the command with a non-zero exit. Nothing is ever silently overwritten.
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { spawnSync } = require('child_process');
|
|
27
|
+
|
|
28
|
+
const { hashContent, readBase } = require('../manifest');
|
|
29
|
+
const render = require('../render');
|
|
30
|
+
|
|
31
|
+
const SHIM_SOURCE = 'shims/jira.js';
|
|
32
|
+
|
|
33
|
+
// Outcomes, most-benign first. CONFLICT and NO_BASE are the non-zero exits.
|
|
34
|
+
const OUTCOME = {
|
|
35
|
+
UNCHANGED: 'unchanged',
|
|
36
|
+
MODIFIED: 'modified',
|
|
37
|
+
CREATED: 'created',
|
|
38
|
+
FAST_FORWARD: 'fast-forward',
|
|
39
|
+
MERGED: 'merged',
|
|
40
|
+
RESTORED: 'restored',
|
|
41
|
+
CONFLICT: 'CONFLICT',
|
|
42
|
+
ORPHANED: 'orphaned',
|
|
43
|
+
NO_BASE: 'no base',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function templatesDir() {
|
|
47
|
+
return path.join(__dirname, '..', '..', 'templates');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// `theirs` for a manifest entry: the preset-rendered file when the presets still
|
|
51
|
+
// emit that path, the vendored template for a tracker shim, or null when neither
|
|
52
|
+
// applies any more (the stack changed and this preset dropped out).
|
|
53
|
+
function resolveTheirs(entry, rel, targetsByRel) {
|
|
54
|
+
const hit = targetsByRel.get(rel);
|
|
55
|
+
if (hit) return { content: hit.content, source: hit.source };
|
|
56
|
+
if (entry.source === SHIM_SOURCE) {
|
|
57
|
+
const file = path.join(templatesDir(), SHIM_SOURCE);
|
|
58
|
+
if (fs.existsSync(file)) return { content: fs.readFileSync(file, 'utf8'), source: SHIM_SOURCE };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// git merge-file --diff3, ours/base/theirs on disk, merged text on stdout. Exit
|
|
64
|
+
// code is the conflict count (0 = clean); a negative code means git failed.
|
|
65
|
+
function gitMergeFile(ours, base, theirs) {
|
|
66
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cairn-merge-'));
|
|
67
|
+
const write = (name, body) => {
|
|
68
|
+
const p = path.join(dir, name);
|
|
69
|
+
fs.writeFileSync(p, body, 'utf8');
|
|
70
|
+
return p;
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const res = spawnSync(
|
|
74
|
+
'git',
|
|
75
|
+
[
|
|
76
|
+
'merge-file',
|
|
77
|
+
'-p',
|
|
78
|
+
'--diff3',
|
|
79
|
+
'-L',
|
|
80
|
+
'ours (your edits)',
|
|
81
|
+
'-L',
|
|
82
|
+
'base (cairn wrote this)',
|
|
83
|
+
'-L',
|
|
84
|
+
'theirs (new template)',
|
|
85
|
+
write('ours', ours),
|
|
86
|
+
write('base', base),
|
|
87
|
+
write('theirs', theirs),
|
|
88
|
+
],
|
|
89
|
+
{ encoding: 'utf8' },
|
|
90
|
+
);
|
|
91
|
+
if (res.error && res.error.code === 'ENOENT') {
|
|
92
|
+
return { ok: false, reason: 'git is not on PATH — cairn sync needs it for the merge step' };
|
|
93
|
+
}
|
|
94
|
+
if (typeof res.status !== 'number' || res.status < 0) {
|
|
95
|
+
return { ok: false, reason: (res.stderr || 'git merge-file failed').trim() };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, merged: res.stdout, conflicts: res.status };
|
|
98
|
+
} finally {
|
|
99
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One decision per managed file. Returns an action describing what the caller
|
|
104
|
+
// should write; it performs no IO of its own beyond reading ours/base.
|
|
105
|
+
function planFile(rel, entry, repoPath, targetsByRel, { adoptBase }) {
|
|
106
|
+
const theirs = resolveTheirs(entry, rel, targetsByRel);
|
|
107
|
+
const absOurs = path.join(repoPath, rel);
|
|
108
|
+
const ours = fs.existsSync(absOurs) ? fs.readFileSync(absOurs, 'utf8') : null;
|
|
109
|
+
|
|
110
|
+
if (!theirs) {
|
|
111
|
+
return { rel, outcome: OUTCOME.ORPHANED, detail: 'the presets no longer emit this path' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (ours === null) {
|
|
115
|
+
// Deleted locally — nothing to preserve, put the current render back.
|
|
116
|
+
return {
|
|
117
|
+
rel,
|
|
118
|
+
outcome: OUTCOME.RESTORED,
|
|
119
|
+
detail: 'was deleted',
|
|
120
|
+
write: theirs.content,
|
|
121
|
+
base: theirs.content,
|
|
122
|
+
source: theirs.source,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let base = readBase(repoPath, rel);
|
|
127
|
+
let seededBase = false;
|
|
128
|
+
if (base === null) {
|
|
129
|
+
if (hashContent(ours) === entry.hash) {
|
|
130
|
+
// Untouched since cairn wrote it — the on-disk bytes are a faithful base.
|
|
131
|
+
base = ours;
|
|
132
|
+
seededBase = true;
|
|
133
|
+
} else if (adoptBase) {
|
|
134
|
+
base = ours;
|
|
135
|
+
seededBase = true;
|
|
136
|
+
} else {
|
|
137
|
+
return {
|
|
138
|
+
rel,
|
|
139
|
+
outcome: OUTCOME.NO_BASE,
|
|
140
|
+
detail: 'rendered before base tracking and edited since — pass --adopt-base or reconcile by hand',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const hOurs = hashContent(ours);
|
|
146
|
+
const hBase = hashContent(base);
|
|
147
|
+
const hTheirs = hashContent(theirs.content);
|
|
148
|
+
const localChanged = hOurs !== hBase;
|
|
149
|
+
const templateChanged = hTheirs !== hBase;
|
|
150
|
+
const hasMarkers = /^<{7} /m.test(ours) && /^>{7} /m.test(ours);
|
|
151
|
+
|
|
152
|
+
if (!templateChanged) {
|
|
153
|
+
// The template has already been folded into the base — anything left is a
|
|
154
|
+
// local edit. Clear a stale conflict flag once the markers are gone.
|
|
155
|
+
const unresolved = entry.conflict && hasMarkers;
|
|
156
|
+
return {
|
|
157
|
+
rel,
|
|
158
|
+
outcome: localChanged ? OUTCOME.MODIFIED : OUTCOME.UNCHANGED,
|
|
159
|
+
detail: unresolved
|
|
160
|
+
? 'conflict markers still in the file — resolve them'
|
|
161
|
+
: localChanged
|
|
162
|
+
? 'local edits, template steady — kept'
|
|
163
|
+
: null,
|
|
164
|
+
// Persist a base we only inferred this run, so the next sync is cheap.
|
|
165
|
+
base: seededBase ? base : undefined,
|
|
166
|
+
clearConflict: entry.conflict && !unresolved,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!localChanged) {
|
|
171
|
+
return {
|
|
172
|
+
rel,
|
|
173
|
+
outcome: OUTCOME.FAST_FORWARD,
|
|
174
|
+
detail: `template moved ${diffMagnitude(base, theirs.content)}`,
|
|
175
|
+
write: theirs.content,
|
|
176
|
+
base: theirs.content,
|
|
177
|
+
source: theirs.source,
|
|
178
|
+
clearConflict: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const merge = gitMergeFile(ours, base, theirs.content);
|
|
183
|
+
if (!merge.ok) {
|
|
184
|
+
return { rel, outcome: OUTCOME.CONFLICT, detail: merge.reason };
|
|
185
|
+
}
|
|
186
|
+
if (merge.conflicts === 0) {
|
|
187
|
+
return {
|
|
188
|
+
rel,
|
|
189
|
+
outcome: OUTCOME.MERGED,
|
|
190
|
+
detail: 'local edits + template changes combined cleanly',
|
|
191
|
+
write: merge.merged,
|
|
192
|
+
base: theirs.content,
|
|
193
|
+
source: theirs.source,
|
|
194
|
+
clearConflict: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// Write the markers, and advance the base to `theirs` so the next run treats
|
|
198
|
+
// whatever the human leaves behind as a plain local edit rather than merging
|
|
199
|
+
// the same template change a second time.
|
|
200
|
+
return {
|
|
201
|
+
rel,
|
|
202
|
+
outcome: OUTCOME.CONFLICT,
|
|
203
|
+
detail: `${merge.conflicts} conflict${merge.conflicts === 1 ? '' : 's'} — markers written, resolve then re-run`,
|
|
204
|
+
write: merge.merged,
|
|
205
|
+
base: theirs.content,
|
|
206
|
+
source: theirs.source,
|
|
207
|
+
conflict: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function diffMagnitude(a, b) {
|
|
212
|
+
const la = a.split('\n').length;
|
|
213
|
+
const lb = b.split('\n').length;
|
|
214
|
+
const d = lb - la;
|
|
215
|
+
if (d === 0) return '(same length)';
|
|
216
|
+
return d > 0 ? `(+${d} lines)` : `(${d} lines)`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Plan a sync for the whole repo. Pure except for reading ours/base and invoking
|
|
220
|
+
// `git merge-file`; the caller writes the files and the manifest.
|
|
221
|
+
function planSync({ config, presetsDir, manifest, repoPath, adoptBase = false }) {
|
|
222
|
+
const { targets } = render.renderTargets({ config, presetsDir });
|
|
223
|
+
const targetsByRel = new Map(targets.map(([rel, content, source]) => [rel, { content, source }]));
|
|
224
|
+
|
|
225
|
+
const actions = [];
|
|
226
|
+
for (const [rel, entry] of Object.entries(manifest.files)) {
|
|
227
|
+
actions.push(planFile(rel, entry, repoPath, targetsByRel, { adoptBase }));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Brand-new preset files: a manifested repo that predates a preset addition.
|
|
231
|
+
// Same greenfield guard as `cairn render` — write it only if nothing is there.
|
|
232
|
+
for (const [rel, content, source] of targets) {
|
|
233
|
+
if (manifest.files[rel]) continue;
|
|
234
|
+
if (fs.existsSync(path.join(repoPath, rel))) continue;
|
|
235
|
+
actions.push({
|
|
236
|
+
rel,
|
|
237
|
+
outcome: OUTCOME.CREATED,
|
|
238
|
+
detail: 'new preset file',
|
|
239
|
+
write: content,
|
|
240
|
+
base: content,
|
|
241
|
+
source,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
actions.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
246
|
+
return { actions };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = { planSync, planFile, gitMergeFile, OUTCOME };
|
package/package.json
CHANGED