@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 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 disk alone (reporting it). The
30
- three-way merge that would re-render over local edits — `cairn sync` — is not built
31
- yet; for a fresh repo there is nothing to merge.
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 (that is `cairn sync`, not built yet). The
51
- boundary is the whole design: hand-edited config living inside a generated directory
52
- is how generated directories acquire state nobody dares regenerate.
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; comes with `cairn sync` |
136
- | `DELETED` | it is gone; a future `cairn sync` will restore it |
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 `DELETED`. `OUTDATED` (needs the
139
- template hashes threaded through) and `cairn sync` (the three-way merge that acts on
140
- these states) are still to come `cairn render` is greenfield-only for now.
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` runs 61 tests.
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
- // v1 surface:
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
- // Template rendering (command stubs, WORKFLOW.md, agent role contracts) is v2 and
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':
@@ -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) => m.error || m.files.some((f) => f.state === 'MODIFIED' || f.state === 'DELETED'),
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) record(manifest, rel, source, content);
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 { readManifest, emptyManifest, record, writeManifest, manifestPath } = require('../manifest');
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
  }
@@ -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
- // Returns { planned: [[rel, content, source, note?]], kept: [{ rel, reason }] }.
133
- // `manifest` + `repoPath` drive the greenfield guard: a file cairn has not
134
- // written is never touched, and one it wrote but a human has since edited is
135
- // kept and reported (the re-render that respects local edits is `cairn sync`).
136
- function planRender({ config, presetsDir, manifest = { files: {} }, repoPath = null }) {
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, cairn will not re-render over your edits',
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 };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Shared AI workflow scaffolding for Stone Panda repos — issue tracker client and drift doctor.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {