relacher 0.0.5-rc.7 → 0.0.5

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.
@@ -7,3 +7,4 @@ export * from './types';
7
7
  export * from './updater';
8
8
  export * from './versioning';
9
9
  export * from './lockfile';
10
+ export * from './init';
@@ -0,0 +1,11 @@
1
+ import { Effect } from 'effect';
2
+ import type { PackageConfig } from './types';
3
+ import { type VcsProvider } from './vcs';
4
+ /**
5
+ * Initializes the `.relacher.lock` file if it does not exist.
6
+ * It resolves the version using each package's `versionFallback`,
7
+ * creates the lockfile, and commits it to establish the baseline.
8
+ */
9
+ export declare function init(packages: PackageConfig[], options?: {
10
+ cwd?: string;
11
+ }): Effect.Effect<void, Error, VcsProvider>;
@@ -35,6 +35,7 @@ export interface DependencyError {
35
35
  export type PreparedUpdate = {
36
36
  isEmpty: boolean;
37
37
  deps: DependencyUpdateReport[];
38
+ isDirty?: boolean;
38
39
  } & ({
39
40
  isInvalid: true;
40
41
  errors: DependencyError[];
@@ -5,5 +5,6 @@ export declare function runGit(cmd: string, cwd: string): Effect.Effect<string,
5
5
  export declare function getGitCommits(name: string, watch: string[], lastCommit: string | null, cwd: string, exclude?: string[]): Effect.Effect<Commit[], never>;
6
6
  export declare function getGitHeadCommit(cwd: string): Effect.Effect<string, never>;
7
7
  export declare function findGitLastReleaseCommit(packageName: string, currentVersion: string, cwd: string): Effect.Effect<string | null, never>;
8
+ export declare function isGitDirty(cwd: string): Effect.Effect<boolean, never>;
8
9
  export declare function makeGitVcsProvider(cwd: string): VcsProvider;
9
10
  export declare const GitVcsProviderLive: (cwd: string) => Layer.Layer<VcsProvider, never, never>;
@@ -1,10 +1,11 @@
1
- import { Context, Effect } from 'effect';
1
+ import { Context, type Effect } from 'effect';
2
2
  import type { Commit } from '../types';
3
3
  export interface VcsProvider {
4
4
  readonly getCommits: (name: string, watch: string[], lastCommit: string | null, exclude?: string[]) => Effect.Effect<Commit[], Error>;
5
5
  readonly getHeadCommit: () => Effect.Effect<string, Error>;
6
6
  readonly findLastReleaseCommit: (packageName: string, currentVersion: string) => Effect.Effect<string | null, Error>;
7
7
  readonly commit: (message: string) => Effect.Effect<void, Error>;
8
+ readonly isDirty: () => Effect.Effect<boolean, Error>;
8
9
  }
9
10
  export declare const VcsProviderService: Context.Service<VcsProvider, VcsProvider>;
10
11
  export * from './git';
@@ -7,5 +7,6 @@ export declare function runJj(cmd: string, cwd: string, opts?: {
7
7
  export declare function getJjCommits(name: string, watch: string[], lastCommit: string | null, cwd: string, exclude?: string[]): Effect.Effect<Commit[], never>;
8
8
  export declare function getJjHeadCommit(cwd: string): Effect.Effect<string, never>;
9
9
  export declare function findJjLastReleaseCommit(packageName: string, currentVersion: string, cwd: string): Effect.Effect<string | null, never>;
10
+ export declare function isJjDirty(cwd: string): Effect.Effect<boolean, never>;
10
11
  export declare function makeJjVcsProvider(cwd: string): VcsProvider;
11
12
  export declare const JjVcsProviderLive: (cwd: string) => Layer.Layer<VcsProvider, never, never>;
@@ -1,3 +1,3 @@
1
1
  import type { CascadeRules, SizePatterns } from './types';
2
2
  export declare const defaultSizes: SizePatterns;
3
- export declare const defaultCascadeRules: Required<CascadeRules>;
3
+ export declare const defaultCascadeRules: CascadeRules;
@@ -12,10 +12,4 @@ export interface SizePatterns {
12
12
  patch: PatternConfig;
13
13
  skip: PatternConfig;
14
14
  }
15
- export interface CascadeRules {
16
- skip?: Record<string, BumpSize>;
17
- patch?: Record<string, BumpSize>;
18
- minor?: Record<string, BumpSize>;
19
- major?: Record<string, BumpSize>;
20
- [key: string]: Record<string, BumpSize> | undefined;
21
- }
15
+ export type CascadeRules = Record<string, BumpSize>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relacher",
3
- "version": "0.0.5-rc.7",
3
+ "version": "0.0.5",
4
4
  "description": "Scriptable release orchestration library for monorepos",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,6 +1,8 @@
1
1
  import { pipe } from 'effect';
2
2
 
3
3
  import type { PreparedUpdate } from '../types';
4
+ import { defaultSizes } from '../versioning/default-data';
5
+ import { matchBumpSize } from '../versioning/utils';
4
6
 
5
7
  import { getIconForFile } from './devicons';
6
8
  import { c, log } from './utils';
@@ -75,6 +77,10 @@ function getVersionDisplay(oldV: string, newV: string, lastStableVersion?: strin
75
77
  }
76
78
 
77
79
  export function prettyPrint(prepared: PreparedUpdate): void {
80
+ if (prepared.isDirty) {
81
+ log.warn('The repository has unstaged or uncommitted changes (dirty status).');
82
+ }
83
+
78
84
  if (prepared.isInvalid) {
79
85
  console.log(c.red('\nErrors detected during preparation:'));
80
86
  for (const err of prepared.errors) {
@@ -116,7 +122,7 @@ export function prettyPrint(prepared: PreparedUpdate): void {
116
122
 
117
123
  // 1. Show all commits since the last release with a bump marker if applicable
118
124
  for (const commit of report.commits) {
119
- const commitBump = 'skip'; //matchBumpSize(commit.message, defaultSizes);
125
+ const commitBump = matchBumpSize(commit.message, defaultSizes);
120
126
  const affectsBump = commitBump !== 'skip';
121
127
 
122
128
  const marker = affectsBump ? `${c.green('✦')}` : `${c.gray('○')}`;
package/src/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './types';
7
7
  export * from './updater';
8
8
  export * from './versioning';
9
9
  export * from './lockfile';
10
+ export * from './init';
package/src/init.ts ADDED
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { Effect } from 'effect';
5
+
6
+ import { log } from './display';
7
+ import { writeLockfile, type LockfileData } from './lockfile';
8
+ import type { PackageConfig } from './types';
9
+ import { VcsProviderService, type VcsProvider } from './vcs';
10
+
11
+ /**
12
+ * Initializes the `.relacher.lock` file if it does not exist.
13
+ * It resolves the version using each package's `versionFallback`,
14
+ * creates the lockfile, and commits it to establish the baseline.
15
+ */
16
+ export function init(
17
+ packages: PackageConfig[],
18
+ options: { cwd?: string } = {},
19
+ ): Effect.Effect<void, Error, VcsProvider> {
20
+ return Effect.gen(function*() {
21
+ const cwd = options.cwd || process.cwd();
22
+ const lockPath = path.resolve(cwd, '.relacher.lock');
23
+
24
+ if (fs.existsSync(lockPath)) {
25
+ return;
26
+ }
27
+
28
+ const vcs = yield* VcsProviderService;
29
+
30
+ // Check for dirty repository status
31
+ const isDirty = yield* vcs.isDirty();
32
+ if (isDirty) {
33
+ return yield* Effect.fail(
34
+ new Error('Cannot initialize lockfile: repository has dirty status.'),
35
+ );
36
+ }
37
+
38
+ const lockfile: LockfileData = { packages: {} };
39
+
40
+ let warn_flag = false;
41
+ for (const pkg of packages) {
42
+ let version = '0.0.0';
43
+ if (pkg.versionFallback) {
44
+ try {
45
+ const fallback = pkg.versionFallback.readFallback(cwd);
46
+ if (fallback) {
47
+ version = fallback;
48
+ }
49
+ } catch (_) {
50
+ log.warn(`Version fallback failed for package ${pkg.name}`);
51
+ warn_flag = true;
52
+ // Fall back to default version if reading fails
53
+ }
54
+ }
55
+
56
+ const isPrerelease = version.includes('-');
57
+ lockfile.packages[pkg.name] = {
58
+ version,
59
+ lastStableVersion: isPrerelease ? undefined : version,
60
+ };
61
+ }
62
+
63
+ if (warn_flag) {
64
+ log.warn(
65
+ 'One or more package failed to retrieve their version, update .relacher.lockfile manually.',
66
+ );
67
+ }
68
+
69
+ writeLockfile(cwd, lockfile);
70
+
71
+ const commitMessage = `chore: initialize .relacher.lock\n\nInitialized package versions from workspace fallbacks.`;
72
+ yield* vcs.commit(commitMessage);
73
+ }) as Effect.Effect<void, Error, VcsProvider>;
74
+ }
package/src/prepare.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import { Effect } from 'effect';
4
+ import { Effect, Option } from 'effect';
5
5
 
6
6
  import type {
7
7
  Commit,
@@ -13,6 +13,7 @@ import type {
13
13
  DependencyError,
14
14
  } from './types';
15
15
  import type { UpdateAction, UpdateActionResolved } from './updater';
16
+ import { VcsProviderService } from './vcs';
16
17
  import { VersionManagerService, type VersionManager } from './versioning';
17
18
  import type { BumpSize } from './versioning/types';
18
19
 
@@ -190,10 +191,14 @@ export function prepare(
190
191
  name: err.name,
191
192
  message: err.message,
192
193
  })) as DependencyError[],
194
+ isDirty: false,
193
195
  };
194
196
  }
195
197
 
196
198
  const versionManager = yield* VersionManagerService;
199
+ const vcsOption = yield* Effect.serviceOption(VcsProviderService);
200
+ const isDirty = Option.isSome(vcsOption) ? yield* vcsOption.value.isDirty() : false;
201
+
197
202
  const cwd = options.cwd || process.cwd();
198
203
 
199
204
  const items = yield* initReportItems(packages, cwd, options.excludeNestedWatches);
@@ -215,6 +220,7 @@ export function prepare(
215
220
  name: r.name,
216
221
  message: `Package ${r.name} is missing a version or a required update file.`,
217
222
  })) as DependencyError[],
223
+ isDirty,
218
224
  };
219
225
  }
220
226
 
@@ -222,6 +228,7 @@ export function prepare(
222
228
  isEmpty,
223
229
  deps,
224
230
  isInvalid: false,
231
+ isDirty,
225
232
  };
226
233
  }) as Effect.Effect<PreparedUpdate, Error, VersionManager>;
227
234
  }
package/src/run.ts CHANGED
@@ -56,6 +56,12 @@ export function run(
56
56
  return Effect.gen(function*() {
57
57
  const vcs = yield* VcsProviderService;
58
58
 
59
+ // Check for dirty repository status
60
+ const isDirty = yield* vcs.isDirty();
61
+ if (isDirty) {
62
+ return yield* Effect.fail(new Error('Cannot run release: repository has dirty status.'));
63
+ }
64
+
59
65
  // Reject run early if preparation is flagged as invalid
60
66
  if (prepared.isInvalid) {
61
67
  const messages = prepared.errors
package/src/types.ts CHANGED
@@ -41,10 +41,8 @@ export interface DependencyError {
41
41
  export type PreparedUpdate = {
42
42
  isEmpty: boolean;
43
43
  deps: DependencyUpdateReport[];
44
- } & (
45
- | { isInvalid: true; errors: DependencyError[] }
46
- | { isInvalid: false }
47
- );
44
+ isDirty?: boolean;
45
+ } & ({ isInvalid: true; errors: DependencyError[] } | { isInvalid: false });
48
46
 
49
47
  export interface DependencyUpdateReport {
50
48
  name: string;
package/src/vcs/git.ts CHANGED
@@ -115,6 +115,12 @@ export function findGitLastReleaseCommit(
115
115
  );
116
116
  }
117
117
 
118
+ export function isGitDirty(cwd: string): Effect.Effect<boolean, never> {
119
+ return runGit('git diff --name-only', cwd).pipe(
120
+ Effect.map((output) => output.trim().length > 0),
121
+ );
122
+ }
123
+
118
124
  // Service Factory Implementation
119
125
  export function makeGitVcsProvider(cwd: string): VcsProvider {
120
126
  return VcsProviderService.of({
@@ -131,6 +137,8 @@ export function makeGitVcsProvider(cwd: string): VcsProvider {
131
137
  Effect.flatMap(() => runGit(`git commit -m "${message}"`, cwd)),
132
138
  Effect.asVoid,
133
139
  ),
140
+
141
+ isDirty: () => isGitDirty(cwd),
134
142
  });
135
143
  }
136
144
 
package/src/vcs/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Context, Effect } from 'effect';
1
+ import { Context, type Effect } from 'effect';
2
2
 
3
3
  import type { Commit } from '../types';
4
4
 
@@ -18,6 +18,8 @@ export interface VcsProvider {
18
18
  ) => Effect.Effect<string | null, Error>;
19
19
 
20
20
  readonly commit: (message: string) => Effect.Effect<void, Error>;
21
+
22
+ readonly isDirty: () => Effect.Effect<boolean, Error>;
21
23
  }
22
24
 
23
25
  export const VcsProviderService = Context.Service<VcsProvider>('VcsProviderService');
package/src/vcs/jj.ts CHANGED
@@ -93,7 +93,6 @@ export function findJjLastReleaseCommit(
93
93
  currentVersion: string,
94
94
  cwd: string,
95
95
  ): Effect.Effect<string | null, never> {
96
- // Query only commits modifying the lockfile using highly-compatible path filtering
97
96
  return runJj('log --no-graph -T \'commit_id ++ "\\n"\' -- .relacher.lock', cwd).pipe(
98
97
  Effect.map((output) => {
99
98
  const hashes = output
@@ -106,7 +105,6 @@ export function findJjLastReleaseCommit(
106
105
 
107
106
  for (const hash of hashes) {
108
107
  try {
109
- // Attempt the retrieval, catching any command or JSON-parsing errors
110
108
  const content = execSync(`jj file show .relacher.lock -r ${hash}`, {
111
109
  cwd,
112
110
  encoding: 'utf8',
@@ -131,6 +129,30 @@ export function findJjLastReleaseCommit(
131
129
  );
132
130
  }
133
131
 
132
+ export function isJjDirty(cwd: string): Effect.Effect<boolean, never> {
133
+ return Effect.gen(function*() {
134
+ // 1. Check if current commit @ is not empty
135
+ const currentStatus = yield* runJj(
136
+ 'log --no-graph -r @ -T \'if(empty, "empty", "not-empty")\'',
137
+ cwd,
138
+ );
139
+ if (currentStatus.trim() === 'not-empty') {
140
+ return true;
141
+ }
142
+
143
+ // 2. Check if there are any commits without a description in the history (excluding root and the current commit)
144
+ const historyDescriptions = yield* runJj(
145
+ 'log --no-graph -r "::@ & ~@ & ~root()" -T \'if(description, "1", "0")\'',
146
+ cwd,
147
+ );
148
+ if (historyDescriptions.includes('0')) {
149
+ return true;
150
+ }
151
+
152
+ return false;
153
+ });
154
+ }
155
+
134
156
  // Service Factory Implementation
135
157
  export function makeJjVcsProvider(cwd: string): VcsProvider {
136
158
  return VcsProviderService.of({
@@ -143,6 +165,8 @@ export function makeJjVcsProvider(cwd: string): VcsProvider {
143
165
  findJjLastReleaseCommit(packageName, currentVersion, cwd),
144
166
 
145
167
  commit: (message) => runJj(`commit -m "${message}"`, cwd).pipe(Effect.asVoid),
168
+
169
+ isDirty: () => isJjDirty(cwd),
146
170
  });
147
171
  }
148
172
 
@@ -7,9 +7,9 @@ export const defaultSizes: SizePatterns = {
7
7
  skip: { pattern: '^release|^chore|^infra|^docs|^test|^ci|^build' },
8
8
  };
9
9
 
10
- export const defaultCascadeRules: Required<CascadeRules> = {
11
- skip: { skip: 'skip', patch: 'patch', minor: 'patch', major: 'patch' },
12
- patch: { skip: 'patch', patch: 'patch', minor: 'minor', major: 'minor' },
13
- minor: { skip: 'minor', patch: 'minor', minor: 'minor', major: 'minor' },
14
- major: { skip: 'major', patch: 'major', minor: 'major', major: 'major' },
10
+ export const defaultCascadeRules: CascadeRules = {
11
+ skip: 'skip',
12
+ patch: 'patch',
13
+ minor: 'patch',
14
+ major: 'minor',
15
15
  };
@@ -12,10 +12,4 @@ export interface SizePatterns {
12
12
  skip: PatternConfig;
13
13
  }
14
14
 
15
- export interface CascadeRules {
16
- skip?: Record<string, BumpSize>;
17
- patch?: Record<string, BumpSize>;
18
- minor?: Record<string, BumpSize>;
19
- major?: Record<string, BumpSize>;
20
- [key: string]: Record<string, BumpSize> | undefined;
21
- }
15
+ export type CascadeRules = Record<string, BumpSize>;
@@ -47,28 +47,6 @@ const getBumpSizeByPriority = (priorityValue: number, originalBump: BumpSize): B
47
47
  return 'skip';
48
48
  };
49
49
 
50
- const getCascadeRule = (
51
- original: BumpSize,
52
- dependencyBump: BumpSize,
53
- activeRules: Record<string, Record<string, BumpSize> | undefined>,
54
- ): BumpSize | undefined => {
55
- const origKey =
56
- typeof original === 'string' ? original : original.size ? `pre-${original.size}` : 'pre';
57
- const depKey =
58
- typeof dependencyBump === 'string'
59
- ? dependencyBump
60
- : dependencyBump.size
61
- ? `pre-${dependencyBump.size}`
62
- : 'pre';
63
-
64
- const rule = activeRules[origKey]?.[depKey];
65
- if (rule !== undefined) return rule;
66
-
67
- const fallBackOrigKey = typeof original === 'string' ? original : 'patch';
68
- const fallBackDepKey = typeof dependencyBump === 'string' ? dependencyBump : 'patch';
69
- return activeRules[fallBackOrigKey]?.[fallBackDepKey];
70
- };
71
-
72
50
  const isVersionGreater = (v1: string, v2: string): boolean => {
73
51
  const parts1 = v1.replace(/^v/, '').split('.').map(Number);
74
52
  const parts2 = v2.replace(/^v/, '').split('.').map(Number);
@@ -175,28 +153,59 @@ export const makeVcsVersionManager = (
175
153
  },
176
154
 
177
155
  propagateBumps: (sorted) => {
178
- const activeRules: Record<string, Record<string, BumpSize> | undefined> = {
179
- skip: { ...defaultCascadeRules.skip, ...cascadeRules.skip },
180
- patch: { ...defaultCascadeRules.patch, ...cascadeRules.patch },
181
- minor: { ...defaultCascadeRules.minor, ...cascadeRules.minor },
182
- major: { ...defaultCascadeRules.major, ...cascadeRules.major },
156
+ const activeRules: Record<string, BumpSize> = {
157
+ patch: 'patch',
158
+ minor: 'patch',
159
+ major: 'patch',
160
+ };
161
+
162
+ // Helper to merge cascade rules from both flat and legacy nested structures defensively
163
+ const mergeRules = (rules: any) => {
164
+ if (!rules) return;
165
+ for (const [key, val] of Object.entries(rules)) {
166
+ if (typeof val === 'string') {
167
+ activeRules[key] = val as BumpSize;
168
+ } else if (val && typeof val === 'object') {
169
+ for (const [subKey, subVal] of Object.entries(val)) {
170
+ if (typeof subVal === 'string') {
171
+ activeRules[subKey] = subVal as BumpSize;
172
+ }
173
+ }
174
+ }
175
+ }
183
176
  };
184
177
 
178
+ mergeRules(defaultCascadeRules);
179
+ mergeRules(cascadeRules);
180
+
185
181
  for (const item of sorted) {
186
- let maxDepBump: BumpSize = 'skip';
182
+ let maxCascadedBump: BumpSize = 'skip';
183
+
187
184
  for (const depName of item.depends) {
188
185
  const depItem = sorted.find((r) => r.name === depName);
189
- if (depItem) {
190
- if (getBumpPriority(depItem.bump) > getBumpPriority(maxDepBump)) {
191
- maxDepBump = depItem.bump;
186
+ if (depItem && depItem.bump !== 'skip') {
187
+ const depBumpKey =
188
+ typeof depItem.bump === 'string'
189
+ ? depItem.bump
190
+ : depItem.bump.kind === 'pre'
191
+ ? depItem.bump.size
192
+ ? `pre-${depItem.bump.size}`
193
+ : 'pre'
194
+ : 'patch';
195
+
196
+ const cascadedResult = activeRules[depBumpKey];
197
+ if (
198
+ cascadedResult &&
199
+ getBumpPriority(cascadedResult) > getBumpPriority(maxCascadedBump)
200
+ ) {
201
+ maxCascadedBump = cascadedResult;
192
202
  }
193
203
  }
194
204
  }
195
205
 
196
- if (getBumpPriority(maxDepBump) > 0) {
197
- const original = item.bump;
198
- const targetBump = getCascadeRule(original, maxDepBump, activeRules) ?? original;
199
- item.bump = targetBump;
206
+ // Apply the cascaded bump only if its priority exceeds the package's existing bump
207
+ if (getBumpPriority(maxCascadedBump) > getBumpPriority(item.bump)) {
208
+ item.bump = maxCascadedBump;
200
209
  }
201
210
 
202
211
  item.newVersion =