changeledger 0.10.0 → 0.11.1

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.
@@ -201,7 +201,7 @@ program
201
201
  '',
202
202
  'When --id is omitted, the single in-progress change is used automatically;',
203
203
  'zero or multiple in-progress changes require --id explicitly. Repeat --id',
204
- 'for a multi-id subject: each id gets its own bracket ([#A] [#B]).',
204
+ 'for a multi-id commit: the clean subject gets a ChangeLedger: [#A] [#B] body.',
205
205
  '',
206
206
  'Examples:',
207
207
  ' changeledger commit -m "feat(cli): add helper"',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@ function checkCommits(args, commitsIdx, cwd, output, json) {
41
41
 
42
42
  const errors = violations.map((v) => ({
43
43
  file: '(commits)',
44
- message: `${v.sha} missing [#id] marker: "${v.subject}"`,
44
+ message: `${v.sha} ${v.reason}: "${v.subject}"`,
45
45
  }));
46
46
 
47
47
  if (json) {
@@ -33,7 +33,12 @@ export function commit({ message, ids = [] } = {}, cwd = process.cwd(), run = mu
33
33
  resolvedIds = [active[0].frontmatter.id];
34
34
  }
35
35
 
36
- const subject = `${message} ${resolvedIds.map((id) => `[#${id}]`).join(' ')}`;
37
- run(['commit', '-m', subject], repo.repoRoot);
36
+ const markers = resolvedIds.map((id) => `[#${id}]`).join(' ');
37
+ const multiple = resolvedIds.length > 1;
38
+ const subject = multiple ? message : `${message} ${markers}`;
39
+ const args = multiple
40
+ ? ['commit', '-m', subject, '-m', `ChangeLedger: ${markers}`]
41
+ : ['commit', '-m', subject];
42
+ run(args, repo.repoRoot);
38
43
  return subject;
39
44
  }
@@ -4,7 +4,7 @@ import { parseDocument } from 'yaml';
4
4
  import { writeFileAtomic } from './atomic-write.mjs';
5
5
  import { templatesDir } from './paths.mjs';
6
6
 
7
- export const SUPPORTED_SCHEMA_VERSION = 2;
7
+ export const SUPPORTED_SCHEMA_VERSION = 3;
8
8
 
9
9
  const CANONICAL_STATUSES = [
10
10
  'draft',
@@ -76,7 +76,8 @@ export function buildMigration(originalText) {
76
76
  if (current < 1) {
77
77
  migrateToV1(doc, config, changes);
78
78
  }
79
- migrateToV2(doc, config, changes);
79
+ if (current < 2) migrateToV2(doc, config, changes);
80
+ if (current < 3) migrateToV3(doc, config, changes);
80
81
 
81
82
  // No line wrapping and no flow padding: keeps untouched flow sequences
82
83
  // (statuses, stages) byte-identical to their common written form.
@@ -164,6 +165,28 @@ function migrateToV2(doc, config, changes) {
164
165
  }
165
166
  }
166
167
 
168
+ // 2 → 3: expose Git integration without inventing a repository-specific branch.
169
+ // Existing git settings and comments remain byte-for-byte owned by the source doc.
170
+ function migrateToV3(doc, config, changes) {
171
+ if (!Object.hasOwn(config, 'git')) {
172
+ setBlankGitSection(doc);
173
+ changes.push('added git section');
174
+ }
175
+ }
176
+
177
+ function setBlankGitSection(doc) {
178
+ const gitNode = parseDocument('git:\n integration_branch:\n').get('git', true);
179
+ doc.set('git', gitNode);
180
+ const gitPair = doc.contents.items.find(
181
+ (pair) => pair.key?.value === 'git' || pair.key === 'git',
182
+ );
183
+ if (!gitPair) return;
184
+ if (typeof gitPair.key === 'string') gitPair.key = doc.createNode(gitPair.key);
185
+ gitPair.key.spaceBefore = true;
186
+ gitPair.key.commentBefore =
187
+ ' Git integration: change branches start from and merge into this branch';
188
+ }
189
+
167
190
  // Apply migration to a file (or dry-run). Returns summary string.
168
191
  export function applyMigration(configFile, { dryRun = false } = {}) {
169
192
  let original;
package/src/git.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  import { execFileSync } from 'node:child_process';
6
6
 
7
7
  const SEP = String.fromCharCode(31); // ASCII unit separator — safe field delimiter
8
+ const RECORD_SEP = String.fromCharCode(30);
8
9
 
9
10
  // Repo-location env vars git itself exports while running a hook (e.g. this
10
11
  // project's own pre-commit). Left inherited, a child `git` call would silently
@@ -111,36 +112,53 @@ export function defaultBaseBranch(repoRoot, run = defaultRun) {
111
112
  );
112
113
  }
113
114
 
114
- // Commits in `range` (e.g. `main..HEAD`): sha, subject and whether each is a
115
- // merge (more than one parent) — the git metadata `check --commits` lints.
115
+ // Commits in `range` (e.g. `main..HEAD`): sha, subject, body and whether each is
116
+ // a merge (more than one parent) — the git metadata `check --commits` lints.
116
117
  export function commitsInRange(repoRoot, range, run = defaultRun) {
117
118
  let out;
118
119
  try {
119
- out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s`], repoRoot);
120
+ out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s${SEP}%b${RECORD_SEP}`], repoRoot);
120
121
  } catch (e) {
121
122
  throw new Error(`git log failed for range "${range}": ${e.message}`);
122
123
  }
123
124
  return out
124
- .split('\n')
125
+ .split(RECORD_SEP)
126
+ .map((record) => record.trim())
125
127
  .filter(Boolean)
126
- .map((line) => {
127
- const [sha, parents, subject] = line.split(SEP);
128
+ .map((record) => {
129
+ const [sha, parents, subject, body = ''] = record.split(SEP);
128
130
  return {
129
131
  sha,
130
132
  subject,
133
+ body: body.trim(),
131
134
  isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
132
135
  };
133
136
  });
134
137
  }
135
138
 
136
- // A well-formed marker is one or more `[#id]` groups, each separated by a
137
- // single space, terminating the subject — the canonical multi-id shape is
138
- // separate brackets (`[#A] [#B]`), never a comma list in one bracket.
139
- const MARKER_RE = /(\[#[^\]\s]+\])(\s\[#[^\]\s]+\])*$/;
139
+ const MARKER_RE = /\[#[^\]\s]+\]$/;
140
+ const ANY_MARKER_RE = /\[#[^\]\s]+\]/g;
141
+ const MULTI_BODY_RE = /^ChangeLedger: (\[#[^\]\s]+\])( \[#[^\]\s]+\])+$/;
142
+
140
143
  export function hasCommitMarker(subject) {
141
144
  return MARKER_RE.test(subject.trim());
142
145
  }
143
146
 
147
+ function commitMarkerViolation({ subject, body }) {
148
+ const subjectMarkers = subject.match(ANY_MARKER_RE) ?? [];
149
+ const trimmedBody = body.trim();
150
+ const hasBodyLabel = trimmedBody.includes('ChangeLedger:');
151
+ const validMultiBody = MULTI_BODY_RE.test(trimmedBody);
152
+
153
+ if (subjectMarkers.length > 1) return 'multiple [#id] markers must be in the body';
154
+ if (hasBodyLabel && !validMultiBody) return 'malformed ChangeLedger body';
155
+ if (subjectMarkers.length === 1 && hasCommitMarker(subject) && !hasBodyLabel) return null;
156
+ if (subjectMarkers.length === 0 && validMultiBody) return null;
157
+ if (subjectMarkers.length === 1 && validMultiBody)
158
+ return 'ambiguous [#id] markers in both subject and body';
159
+ return 'missing [#id] marker';
160
+ }
161
+
144
162
  // Lints `range`: every non-merge commit must carry a well-formed `[#id]`
145
163
  // marker, except `chore(release)` prep commits. Returns only the violations
146
164
  // (sha + subject); never throws for a clean range.
@@ -150,8 +168,8 @@ export function lintCommitRange(repoRoot, range, run = defaultRun) {
150
168
  for (const c of commits) {
151
169
  if (c.isMerge) continue;
152
170
  if (/^chore\(release\):/.test(c.subject)) continue;
153
- if (!hasCommitMarker(c.subject))
154
- violations.push({ sha: c.sha.slice(0, 7), subject: c.subject });
171
+ const reason = commitMarkerViolation(c);
172
+ if (reason) violations.push({ sha: c.sha.slice(0, 7), subject: c.subject, reason });
155
173
  }
156
174
  return violations;
157
175
  }
@@ -468,6 +468,7 @@ const PATCH_ALLOWED = new Set([
468
468
  'readiness',
469
469
  'types',
470
470
  'release',
471
+ 'git',
471
472
  ]);
472
473
 
473
474
  const CANONICAL_STATUSES_REQUIRED = new Set([
@@ -500,6 +501,8 @@ function applyPatch(doc, patch, currentConfig) {
500
501
  applyReleasePatch(doc, value);
501
502
  } else if (key === 'readiness') {
502
503
  applyReadinessPatch(doc, value);
504
+ } else if (key === 'git') {
505
+ applyGitPatch(doc, value);
503
506
  } else if (key === 'statuses') {
504
507
  applyRequiredListPatch(doc, 'statuses', value, CANONICAL_STATUSES_REQUIRED);
505
508
  } else if (key === 'stages') {
@@ -554,3 +557,12 @@ function applyReadinessPatch(doc, readinessPatch) {
554
557
  doc.setIn(['readiness', 'verification_patterns'], readinessPatch.verification_patterns);
555
558
  }
556
559
  }
560
+
561
+ function applyGitPatch(doc, gitPatch) {
562
+ if (!gitPatch || typeof gitPatch !== 'object') return;
563
+ if (typeof gitPatch.integration_branch === 'string' && gitPatch.integration_branch.trim()) {
564
+ doc.setIn(['git', 'integration_branch'], gitPatch.integration_branch.trim());
565
+ } else if (gitPatch.integration_branch === null) {
566
+ doc.deleteIn(['git', 'integration_branch']);
567
+ }
568
+ }
@@ -150,7 +150,7 @@ export function choiceFilterSummary(label, selected, includeUnassigned = false)
150
150
  : `All ${label.toLowerCase()}s`;
151
151
  }
152
152
 
153
- function renderChoiceFilter(host, label, choices, selected, toggle, clear, owners = false) {
153
+ export function renderChoiceFilter(host, label, choices, selected, toggle, clear, owners = false) {
154
154
  const summary = choiceFilterSummary(label, selected, owners && state.filters.includeUnassigned);
155
155
  litRender(
156
156
  html`<details class="filter-menu">
@@ -169,37 +169,29 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
169
169
  </details>`,
170
170
  host,
171
171
  );
172
- const syncSummary = () => {
173
- host.querySelector('[data-choice-summary]').textContent = choiceFilterSummary(
174
- label,
175
- selected,
176
- owners && state.filters.includeUnassigned,
177
- );
178
- };
172
+ const syncFilter = () =>
173
+ renderChoiceFilter(host, label, choices, selected, toggle, clear, owners);
179
174
  host.querySelectorAll('[data-choice]').forEach((input) => {
180
175
  input.onchange = () => {
181
176
  toggle(input.dataset.choice);
182
- syncSummary();
177
+ syncFilter();
183
178
  render();
184
179
  };
185
180
  });
186
181
  if (owners)
187
182
  host.querySelector('[data-unassigned]').onchange = () => {
188
183
  toggleUnassignedOwner();
189
- syncSummary();
184
+ syncFilter();
190
185
  render();
191
186
  };
192
187
  host.querySelector('[data-clear]').onclick = () => {
193
188
  clear();
194
- host.querySelectorAll('[data-choice], [data-unassigned]').forEach((input) => {
195
- input.checked = false;
196
- });
197
- syncSummary();
189
+ syncFilter();
198
190
  render();
199
191
  };
200
192
  }
201
193
 
202
- function renderStatusFilter() {
194
+ export function renderStatusFilter() {
203
195
  const sf = $('#status-filter');
204
196
  litRender(
205
197
  html`<details class="filter-menu">
@@ -239,24 +231,23 @@ function renderStatusFilter() {
239
231
  sf.querySelectorAll('[data-status]').forEach((input) => {
240
232
  input.onchange = () => {
241
233
  toggleStatusFilter(input.dataset.status);
242
- sf.querySelector('[data-status-summary]').textContent = statusSummary(state.filters.statuses);
234
+ renderStatusFilter();
243
235
  render();
244
236
  };
245
237
  });
246
238
  sf.querySelector('[data-clear-status]').onclick = () => {
247
239
  clearStatusFilters();
248
- sf.querySelectorAll('[data-status], [data-visibility]').forEach((input) => {
249
- input.checked = false;
250
- });
251
- sf.querySelector('[data-status-summary]').textContent = statusSummary(state.filters.statuses);
240
+ renderStatusFilter();
252
241
  render();
253
242
  };
254
243
  sf.querySelector('[data-visibility="archived"]').onchange = () => {
255
244
  toggleShowArchived();
245
+ renderStatusFilter();
256
246
  render();
257
247
  };
258
248
  sf.querySelector('[data-visibility="discarded"]').onchange = () => {
259
249
  toggleShowDiscarded();
250
+ renderStatusFilter();
260
251
  render();
261
252
  };
262
253
  }
@@ -1094,6 +1085,14 @@ function formEditorTemplate(config) {
1094
1085
  </label>
1095
1086
  </fieldset>
1096
1087
 
1088
+ <fieldset class="config-group">
1089
+ <legend>Git</legend>
1090
+ <label>Integration branch
1091
+ <input name="integration_branch" .value=${cfg.git?.integration_branch ?? ''} placeholder="Auto-detect" />
1092
+ </label>
1093
+ <p class="config-note">Change branches start from and merge into this branch. Leave empty to auto-detect.</p>
1094
+ </fieldset>
1095
+
1097
1096
  <fieldset class="config-group">
1098
1097
  <legend>Lifecycle statuses</legend>
1099
1098
  <label>Status order (one per line)
@@ -1339,6 +1338,13 @@ export function collectFormPatch(formEl, currentConfig) {
1339
1338
  if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
1340
1339
  patch.specs_dir = els.specs_dir.value;
1341
1340
  }
1341
+ if (els.integration_branch) {
1342
+ const currentBranch = currentConfig.git?.integration_branch ?? '';
1343
+ const proposedBranch = els.integration_branch.value.trim();
1344
+ if (proposedBranch !== currentBranch) {
1345
+ patch.git = { integration_branch: proposedBranch || null };
1346
+ }
1347
+ }
1342
1348
 
1343
1349
  const statuses = listFromControl(els.statuses);
1344
1350
  if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
@@ -3,7 +3,7 @@
3
3
 
4
4
  # Schema version — used to detect and migrate outdated configurations.
5
5
  # Run `changeledger config migrate --dry-run` to inspect available migrations.
6
- schema_version: 2
6
+ schema_version: 3
7
7
 
8
8
  # Language for generated documentation CONTENT. Structure (headings, keys, enums)
9
9
  # is always English. See `changeledger context spec`.
@@ -35,6 +35,10 @@ release:
35
35
  changes_dir: .changeledger/changes
36
36
  specs_dir: .changeledger/specs
37
37
 
38
+ # Git integration: change branches start from and merge into this branch
39
+ git:
40
+ integration_branch:
41
+
38
42
  # Valid lifecycle statuses (order = progress)
39
43
  statuses: [draft, approved, in-progress, in-review, in-validation, blocked, done, discarded]
40
44
 
@@ -37,15 +37,17 @@ Commit messages use the canonical shape:
37
37
  feat(scope): description [#20260629-234939]
38
38
  ```
39
39
 
40
- Use the actual change id and the appropriate conventional type. Referencing more
41
- than one change is separate brackets, one per id (`[#A] [#B]`) never a comma
42
- list inside one bracket. Ledger meta-commits (status, review, log, archive)
43
- carry the marker like any other commit; only merge commits and `chore(release)`
44
- prep are exempt. `changeledger commit -m "<type>(<scope>): <desc>" [--id <id>]...`
45
- composes and creates the commit for you: it resolves the single `in-progress`
46
- change automatically when `--id` is omitted, and fails without committing if
47
- that is ambiguous or the subject isn't conventional. `changeledger check
48
- --commits [<base>]` lints `<base>..HEAD` for the marker with the same
40
+ Use the actual change id and the appropriate conventional type. One change keeps
41
+ its marker at the end of the subject. Referencing more than one change keeps the
42
+ subject clean and puts separate brackets in one canonical body line:
43
+ `ChangeLedger: [#A] [#B]` never a comma list in one bracket. Ledger
44
+ meta-commits (status, review, log, archive) carry markers like any other commit;
45
+ only merge commits and `chore(release)` prep are exempt. `changeledger commit -m
46
+ "<type>(<scope>): <desc>" [--id <id>]...` composes and creates the commit for
47
+ you: it resolves the single `in-progress` change automatically when `--id` is
48
+ omitted, and fails without committing if that is ambiguous or the subject isn't
49
+ conventional. `changeledger check --commits [<base>]` lints `<base>..HEAD` for
50
+ either canonical marker placement with the same
49
51
  exemptions — run it before requesting review. If shared files make a combined commit
50
52
  unavoidable, record it in Log or the handoff and name every change sharing the
51
53
  surface.