changeledger 0.10.0 → 0.11.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.
@@ -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.0",
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
+ }
@@ -1094,6 +1094,14 @@ function formEditorTemplate(config) {
1094
1094
  </label>
1095
1095
  </fieldset>
1096
1096
 
1097
+ <fieldset class="config-group">
1098
+ <legend>Git</legend>
1099
+ <label>Integration branch
1100
+ <input name="integration_branch" .value=${cfg.git?.integration_branch ?? ''} placeholder="Auto-detect" />
1101
+ </label>
1102
+ <p class="config-note">Change branches start from and merge into this branch. Leave empty to auto-detect.</p>
1103
+ </fieldset>
1104
+
1097
1105
  <fieldset class="config-group">
1098
1106
  <legend>Lifecycle statuses</legend>
1099
1107
  <label>Status order (one per line)
@@ -1339,6 +1347,13 @@ export function collectFormPatch(formEl, currentConfig) {
1339
1347
  if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
1340
1348
  patch.specs_dir = els.specs_dir.value;
1341
1349
  }
1350
+ if (els.integration_branch) {
1351
+ const currentBranch = currentConfig.git?.integration_branch ?? '';
1352
+ const proposedBranch = els.integration_branch.value.trim();
1353
+ if (proposedBranch !== currentBranch) {
1354
+ patch.git = { integration_branch: proposedBranch || null };
1355
+ }
1356
+ }
1342
1357
 
1343
1358
  const statuses = listFromControl(els.statuses);
1344
1359
  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.