@chamba/core 0.2.1 → 0.3.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/dist/index.d.ts CHANGED
@@ -70,6 +70,35 @@ interface FilesystemPort {
70
70
  remove(path: string): Promise<void>;
71
71
  }
72
72
 
73
+ type WorktreeLayout = 'sibling' | 'nested';
74
+ interface WorktreeConfig {
75
+ /**
76
+ * `sibling`: one folder per ticket under `<workspaceRoot>/<root>/<ticket>/<repo>`.
77
+ * `nested`: a worktree under each repo at `<workspaceRoot>/<repo>/<root>/<ticket>`.
78
+ */
79
+ layout: WorktreeLayout;
80
+ /** Worktree root: relative to workspace root (sibling) or to each repo (nested). */
81
+ root: string;
82
+ /** Branch prefix; the branch is `<branchPrefix><ticket>`, shared across repos. */
83
+ branchPrefix: string;
84
+ /** Branch to fork from when the ticket branch doesn't exist yet. */
85
+ baseBranch: string;
86
+ /** Copy git-ignored `.env*` files into the new worktree (off by default). */
87
+ copyEnvFiles: boolean;
88
+ /** Directories pruned while scanning for `.env*` files. */
89
+ envPruneDirs: string[];
90
+ /** Generate an editor workspace file (`.code-workspace`) for the ticket. */
91
+ editorWorkspace: 'code-workspace' | null;
92
+ /** Repos to act on; `null` means autodetect the workspace's git repos. */
93
+ repos: string[] | null;
94
+ /** Escape hatch: if set, chamba shells out to this command instead of the built-in. */
95
+ command: string | null;
96
+ }
97
+ type PartialWorktreeConfig = Partial<WorktreeConfig>;
98
+ declare const DEFAULT_WORKTREE_CONFIG: WorktreeConfig;
99
+ /** Merge a partial (on-disk) worktree config over the compiled defaults. */
100
+ declare function resolveWorktreeConfig(file?: PartialWorktreeConfig): WorktreeConfig;
101
+
73
102
  /** Raised when a config is invalid and the caller asked to fail hard. */
74
103
  declare class ConfigError extends Error {
75
104
  constructor(message: string);
@@ -84,6 +113,8 @@ interface ConfigSource {
84
113
  }
85
114
  interface LoadConfigResult {
86
115
  config: ResolvedConfig;
116
+ /** Resolved multi-repo worktree policy (defaults ← global ← project). */
117
+ worktrees: WorktreeConfig;
87
118
  sources: ConfigSource[];
88
119
  }
89
120
  interface LoadConfigOptions {
@@ -109,10 +140,42 @@ declare function resolveRole(config: ResolvedConfig, role: AgentRole): AgentConf
109
140
  */
110
141
  declare function buildHint(role: AgentRole, cfg: AgentConfig): string;
111
142
 
143
+ /** The `worktrees` block of a config file (all fields optional). */
144
+ declare const worktreeConfigSchema: z.ZodObject<{
145
+ layout: z.ZodOptional<z.ZodEnum<["sibling", "nested"]>>;
146
+ root: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
147
+ branchPrefix: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
148
+ baseBranch: z.ZodOptional<z.ZodString>;
149
+ copyEnvFiles: z.ZodOptional<z.ZodBoolean>;
150
+ envPruneDirs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
151
+ editorWorkspace: z.ZodOptional<z.ZodNullable<z.ZodEnum<["code-workspace"]>>>;
152
+ repos: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString, "many">>>;
153
+ command: z.ZodOptional<z.ZodNullable<z.ZodString>>;
154
+ }, "strict", z.ZodTypeAny, {
155
+ layout?: "sibling" | "nested" | undefined;
156
+ root?: string | undefined;
157
+ branchPrefix?: string | undefined;
158
+ baseBranch?: string | undefined;
159
+ copyEnvFiles?: boolean | undefined;
160
+ envPruneDirs?: string[] | undefined;
161
+ editorWorkspace?: "code-workspace" | null | undefined;
162
+ repos?: string[] | null | undefined;
163
+ command?: string | null | undefined;
164
+ }, {
165
+ layout?: "sibling" | "nested" | undefined;
166
+ root?: string | undefined;
167
+ branchPrefix?: string | undefined;
168
+ baseBranch?: string | undefined;
169
+ copyEnvFiles?: boolean | undefined;
170
+ envPruneDirs?: string[] | undefined;
171
+ editorWorkspace?: "code-workspace" | null | undefined;
172
+ repos?: string[] | null | undefined;
173
+ command?: string | null | undefined;
174
+ }>;
112
175
  /**
113
- * What a config file on disk may contain. Both `defaults` and `overrides` are
114
- * optional and partial per role a project file can carry just one role's one
115
- * field. Unknown roles and unknown models are rejected with clear messages.
176
+ * What a config file on disk may contain. `defaults`/`overrides` are optional
177
+ * and partial per role; `worktrees` is the optional multi-repo worktree policy.
178
+ * Unknown roles, unknown models and unknown keys are rejected with clear messages.
116
179
  */
117
180
  declare const configFileSchema: z.ZodObject<{
118
181
  version: z.ZodLiteral<1>;
@@ -142,6 +205,37 @@ declare const configFileSchema: z.ZodObject<{
142
205
  effort?: Effort | undefined;
143
206
  reasoning_priority?: ReasoningPriority | undefined;
144
207
  }>>>;
208
+ worktrees: z.ZodOptional<z.ZodObject<{
209
+ layout: z.ZodOptional<z.ZodEnum<["sibling", "nested"]>>;
210
+ root: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
211
+ branchPrefix: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
212
+ baseBranch: z.ZodOptional<z.ZodString>;
213
+ copyEnvFiles: z.ZodOptional<z.ZodBoolean>;
214
+ envPruneDirs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
215
+ editorWorkspace: z.ZodOptional<z.ZodNullable<z.ZodEnum<["code-workspace"]>>>;
216
+ repos: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString, "many">>>;
217
+ command: z.ZodOptional<z.ZodNullable<z.ZodString>>;
218
+ }, "strict", z.ZodTypeAny, {
219
+ layout?: "sibling" | "nested" | undefined;
220
+ root?: string | undefined;
221
+ branchPrefix?: string | undefined;
222
+ baseBranch?: string | undefined;
223
+ copyEnvFiles?: boolean | undefined;
224
+ envPruneDirs?: string[] | undefined;
225
+ editorWorkspace?: "code-workspace" | null | undefined;
226
+ repos?: string[] | null | undefined;
227
+ command?: string | null | undefined;
228
+ }, {
229
+ layout?: "sibling" | "nested" | undefined;
230
+ root?: string | undefined;
231
+ branchPrefix?: string | undefined;
232
+ baseBranch?: string | undefined;
233
+ copyEnvFiles?: boolean | undefined;
234
+ envPruneDirs?: string[] | undefined;
235
+ editorWorkspace?: "code-workspace" | null | undefined;
236
+ repos?: string[] | null | undefined;
237
+ command?: string | null | undefined;
238
+ }>>;
145
239
  }, "strict", z.ZodTypeAny, {
146
240
  version: 1;
147
241
  defaults?: Partial<Record<AgentRole, {
@@ -154,6 +248,17 @@ declare const configFileSchema: z.ZodObject<{
154
248
  effort?: Effort | undefined;
155
249
  reasoning_priority?: ReasoningPriority | undefined;
156
250
  }>> | undefined;
251
+ worktrees?: {
252
+ layout?: "sibling" | "nested" | undefined;
253
+ root?: string | undefined;
254
+ branchPrefix?: string | undefined;
255
+ baseBranch?: string | undefined;
256
+ copyEnvFiles?: boolean | undefined;
257
+ envPruneDirs?: string[] | undefined;
258
+ editorWorkspace?: "code-workspace" | null | undefined;
259
+ repos?: string[] | null | undefined;
260
+ command?: string | null | undefined;
261
+ } | undefined;
157
262
  }, {
158
263
  version: 1;
159
264
  defaults?: Partial<Record<AgentRole, {
@@ -166,6 +271,17 @@ declare const configFileSchema: z.ZodObject<{
166
271
  effort?: Effort | undefined;
167
272
  reasoning_priority?: ReasoningPriority | undefined;
168
273
  }>> | undefined;
274
+ worktrees?: {
275
+ layout?: "sibling" | "nested" | undefined;
276
+ root?: string | undefined;
277
+ branchPrefix?: string | undefined;
278
+ baseBranch?: string | undefined;
279
+ copyEnvFiles?: boolean | undefined;
280
+ envPruneDirs?: string[] | undefined;
281
+ editorWorkspace?: "code-workspace" | null | undefined;
282
+ repos?: string[] | null | undefined;
283
+ command?: string | null | undefined;
284
+ } | undefined;
169
285
  }>;
170
286
  type ConfigFile = z.infer<typeof configFileSchema>;
171
287
  type ParseResult = {
@@ -561,6 +677,59 @@ declare function buildBranchName(input: BranchNameInput): string;
561
677
  /** Worktree directory relative to the repo root. */
562
678
  declare function worktreeRelativePath(taskSlug: string, workerId: string): string;
563
679
 
680
+ interface WorktreePlanItem {
681
+ /** Repo name (a directory under the workspace root). */
682
+ repo: string;
683
+ /** Absolute path to the main repo checkout. */
684
+ repoPath: string;
685
+ /** Absolute path where the worktree will be created. */
686
+ worktreePath: string;
687
+ /** The ticket branch (shared across repos). */
688
+ branch: string;
689
+ }
690
+ interface PlanWorktreesInput {
691
+ workspaceRoot: string;
692
+ ticket: string;
693
+ /** Already-resolved repo names. */
694
+ repos: string[];
695
+ config: WorktreeConfig;
696
+ }
697
+ /**
698
+ * Make a ticket id safe as a single git ref / path component, **preserving
699
+ * case** (git refs are case-sensitive, so `TICKET-123` must not become
700
+ * `ticket-123` or it wouldn't match an existing branch).
701
+ */
702
+ declare function safeTicket(ticket: string): string;
703
+ /** Branch shared across repos: `<branchPrefix><ticket>`. */
704
+ declare function buildTicketBranch(branchPrefix: string, ticket: string): string;
705
+ /** Where a repo's worktree lives, per layout. */
706
+ declare function worktreePathFor(config: WorktreeConfig, workspaceRoot: string, ticket: string, repo: string): string;
707
+ /** Directory where the editor workspace file is written. */
708
+ declare function editorWorkspaceDir(config: WorktreeConfig, workspaceRoot: string, ticket: string): string;
709
+ /** Pure plan: one item per repo. No IO. */
710
+ declare function planWorktrees(input: PlanWorktreesInput): WorktreePlanItem[];
711
+ /**
712
+ * Content of the `.code-workspace` file: each repo's worktree as a folder.
713
+ * Paths are made relative to `baseDir` when nested under it (the sibling case,
714
+ * where they become just the repo name), otherwise absolute.
715
+ */
716
+ declare function editorWorkspaceContent(items: WorktreePlanItem[], baseDir: string): string;
717
+
718
+ /**
719
+ * Write a `<ticket>.code-workspace` into `dir` listing each repo's worktree as a
720
+ * folder. Returns the file path. Editor-agnostic (Cursor/VS Code share the format).
721
+ */
722
+ declare function writeEditorWorkspace(fs: FilesystemPort, dir: string, ticket: string, items: WorktreePlanItem[]): Promise<string>;
723
+
724
+ /**
725
+ * Copy git-ignored `.env*` files from `src` into `dst`, preserving relative
726
+ * paths (so nested ones land in the right subdir). Worktrees share the repo's
727
+ * tracked files but NOT untracked/ignored ones, so without this a local `.env`
728
+ * would be missing. Heavy/generated dirs in `pruneDirs` are skipped. Returns the
729
+ * number of files copied.
730
+ */
731
+ declare function copyEnvFiles(fs: FilesystemPort, src: string, dst: string, pruneDirs: string[]): Promise<number>;
732
+
564
733
  /**
565
734
  * Detect whether a directory is inside a git work tree, via
566
735
  * `git rev-parse --is-inside-work-tree`. Caches per root for the session.
@@ -572,6 +741,13 @@ declare class GitDetector {
572
741
  isGitRepo(root: string): Promise<boolean>;
573
742
  }
574
743
 
744
+ /**
745
+ * The immediate child directories of `workspaceRoot` that are git repos
746
+ * (have a `.git` directory). A `.git` *file* marks a linked worktree, which is
747
+ * deliberately excluded — consistent with the workspace scanner.
748
+ */
749
+ declare function detectGitRepos(fs: FilesystemPort, workspaceRoot: string): Promise<string[]>;
750
+
575
751
  declare class WorktreeError extends Error {
576
752
  readonly name = "WorktreeError";
577
753
  }
@@ -616,4 +792,47 @@ declare class WorktreeManager {
616
792
  cleanup(root: string, branch: string): Promise<CleanupResult>;
617
793
  }
618
794
 
619
- export { AGENT_ROLES, type AgentConfig, type AgentRole, type BranchNameInput, type BuiltContext, type ChambaConfig, type CleanupResult, type ClockPort, ConfigError, type ConfigFile, type ConfigSource, type ConfigSourceKind, type ContextBuildInput, ContextBuilder, type CreateWorktreeInput, DEFAULT_CONFIG, type DetectOptions, type DirEntry, EFFORT_LEVELS, type Effort, FakeProcess, FilesystemMemoryStore, type FilesystemPort, type GeneratePlanInput, GitDetector, type Issue, type IssueSeverity, type ListedWorktree, type LoadConfigOptions, type LoadConfigResult, MEMORY_DIR, MODEL_CATALOG, type Memory, MemoryFilesystem, type MemoryStore, type ModelInfo, type ModelProvider, type NoteFields, ObsidianDetector, type ParseResult, type PlanReview, type ProcessExecOptions, type ProcessHandler, type ProcessPort, type ProcessResult, type ProjectRef, REASONING_PRIORITIES, ROLE_DESCRIPTIONS, type ReasoningPriority, type RecordedCall, type RelevantNote, type RememberInput, type ResolvedConfig, type ReviewInput, Reviewer, type SubtaskSpec, VAULT_NOTES_DIR, type ValidatePlanInput, type ValidationResult, type VaultDetection, VaultWriter, WORKSPACE_DIR, WORKSPACE_FILE, WORKSPACE_RELATIVE_PATH, type WorkerKind, type Workspace, WorkspaceScanner, WorktreeError, type WorktreeHandle, WorktreeManager, type WriteNoteInput, type WriteNoteResult, basename, buildBranchName, buildHint, configFileSchema, diffLines, dirname, extname, generatePlanTemplate, getModel, joinPath, loadConfig, modelsByProvider, parseChambaConfig, renderNote, renderWorkspaceMarkdown, resolveEffort, resolveRole, slugify, slugifyForGit, suggestFilesLikelyTouched, suggestSubtasks, textsEqual, validatePlan, worktreeRelativePath };
795
+ type WorktreeStatus = 'created' | 'reused-local' | 'reused-remote' | 'skipped-exists' | 'skipped-not-git';
796
+ interface MultiRepoWorktreeResult {
797
+ repo: string;
798
+ branch: string;
799
+ worktreePath: string;
800
+ status: WorktreeStatus;
801
+ envCopied: number;
802
+ }
803
+ interface CreateMultiInput {
804
+ items: WorktreePlanItem[];
805
+ baseBranch: string;
806
+ copyEnvFiles: boolean;
807
+ envPruneDirs: string[];
808
+ }
809
+ interface CleanupMultiResult {
810
+ repo: string;
811
+ branch: string;
812
+ removed: boolean;
813
+ mergeSuggestion: string;
814
+ }
815
+ /**
816
+ * Create and clean up git worktrees across several repos for one ticket.
817
+ *
818
+ * Mirrors the battle-tested manual flow: reuse an existing local/remote branch
819
+ * or fork a new one from the base branch; optionally copy git-ignored `.env*`
820
+ * files (worktrees don't carry untracked files). Same safety guarantees as the
821
+ * single-repo manager: cleanup NEVER deletes branches and NEVER uses `--force`.
822
+ */
823
+ declare class MultiRepoWorktreeManager {
824
+ private readonly process;
825
+ private readonly fs;
826
+ constructor(process: ProcessPort, fs: FilesystemPort);
827
+ create(input: CreateMultiInput): Promise<MultiRepoWorktreeResult[]>;
828
+ private createOne;
829
+ private addWorktree;
830
+ cleanup(items: WorktreePlanItem[]): Promise<CleanupMultiResult[]>;
831
+ private mergeSuggestion;
832
+ private isGitRepo;
833
+ private branchExistsLocal;
834
+ private branchExistsRemote;
835
+ private git;
836
+ }
837
+
838
+ export { AGENT_ROLES, type AgentConfig, type AgentRole, type BranchNameInput, type BuiltContext, type ChambaConfig, type CleanupMultiResult, type CleanupResult, type ClockPort, ConfigError, type ConfigFile, type ConfigSource, type ConfigSourceKind, type ContextBuildInput, ContextBuilder, type CreateMultiInput, type CreateWorktreeInput, DEFAULT_CONFIG, DEFAULT_WORKTREE_CONFIG, type DetectOptions, type DirEntry, EFFORT_LEVELS, type Effort, FakeProcess, FilesystemMemoryStore, type FilesystemPort, type GeneratePlanInput, GitDetector, type Issue, type IssueSeverity, type ListedWorktree, type LoadConfigOptions, type LoadConfigResult, MEMORY_DIR, MODEL_CATALOG, type Memory, MemoryFilesystem, type MemoryStore, type ModelInfo, type ModelProvider, MultiRepoWorktreeManager, type MultiRepoWorktreeResult, type NoteFields, ObsidianDetector, type ParseResult, type PartialWorktreeConfig, type PlanReview, type PlanWorktreesInput, type ProcessExecOptions, type ProcessHandler, type ProcessPort, type ProcessResult, type ProjectRef, REASONING_PRIORITIES, ROLE_DESCRIPTIONS, type ReasoningPriority, type RecordedCall, type RelevantNote, type RememberInput, type ResolvedConfig, type ReviewInput, Reviewer, type SubtaskSpec, VAULT_NOTES_DIR, type ValidatePlanInput, type ValidationResult, type VaultDetection, VaultWriter, WORKSPACE_DIR, WORKSPACE_FILE, WORKSPACE_RELATIVE_PATH, type WorkerKind, type Workspace, WorkspaceScanner, type WorktreeConfig, WorktreeError, type WorktreeHandle, type WorktreeLayout, WorktreeManager, type WorktreePlanItem, type WorktreeStatus, type WriteNoteInput, type WriteNoteResult, basename, buildBranchName, buildHint, buildTicketBranch, configFileSchema, copyEnvFiles, detectGitRepos, diffLines, dirname, editorWorkspaceContent, editorWorkspaceDir, extname, generatePlanTemplate, getModel, joinPath, loadConfig, modelsByProvider, parseChambaConfig, planWorktrees, renderNote, renderWorkspaceMarkdown, resolveEffort, resolveRole, resolveWorktreeConfig, safeTicket, slugify, slugifyForGit, suggestFilesLikelyTouched, suggestSubtasks, textsEqual, validatePlan, worktreeConfigSchema, worktreePathFor, worktreeRelativePath, writeEditorWorkspace };
package/dist/index.js CHANGED
@@ -196,10 +196,27 @@ var agentConfigSchema = z.object({
196
196
  reasoning_priority: reasoningPrioritySchema
197
197
  });
198
198
  var partialAgentConfigSchema = agentConfigSchema.partial();
199
+ var safePathSchema = z.string().refine((s) => !s.includes("..") && !s.startsWith("/"), {
200
+ message: 'must be a relative path without ".."'
201
+ });
202
+ var worktreeConfigSchema = z.object({
203
+ layout: z.enum(["sibling", "nested"]).optional(),
204
+ root: safePathSchema.optional(),
205
+ branchPrefix: z.string().refine((s) => !s.includes("..") && !/\s/.test(s), {
206
+ message: 'branchPrefix must have no spaces and no ".."'
207
+ }).optional(),
208
+ baseBranch: z.string().min(1).optional(),
209
+ copyEnvFiles: z.boolean().optional(),
210
+ envPruneDirs: z.array(z.string()).optional(),
211
+ editorWorkspace: z.enum(["code-workspace"]).nullable().optional(),
212
+ repos: z.array(z.string()).nullable().optional(),
213
+ command: z.string().nullable().optional()
214
+ }).strict();
199
215
  var configFileSchema = z.object({
200
216
  version: z.literal(1),
201
217
  defaults: z.record(roleSchema, partialAgentConfigSchema).optional(),
202
- overrides: z.record(roleSchema, partialAgentConfigSchema).optional()
218
+ overrides: z.record(roleSchema, partialAgentConfigSchema).optional(),
219
+ worktrees: worktreeConfigSchema.optional()
203
220
  }).strict();
204
221
  function parseChambaConfig(raw) {
205
222
  const result = configFileSchema.safeParse(raw);
@@ -208,6 +225,46 @@ function parseChambaConfig(raw) {
208
225
  return { ok: false, error };
209
226
  }
210
227
 
228
+ // src/config/worktrees.ts
229
+ var DEFAULT_WORKTREE_CONFIG = {
230
+ layout: "sibling",
231
+ root: "WORKTREES",
232
+ branchPrefix: "chamba/",
233
+ baseBranch: "main",
234
+ copyEnvFiles: false,
235
+ envPruneDirs: [
236
+ "node_modules",
237
+ ".git",
238
+ ".next",
239
+ "dist",
240
+ "build",
241
+ ".venv",
242
+ "cdk.out",
243
+ ".aws-sam",
244
+ ".turbo",
245
+ "coverage"
246
+ ],
247
+ editorWorkspace: null,
248
+ repos: null,
249
+ command: null
250
+ };
251
+ function resolveWorktreeConfig(file) {
252
+ const d = DEFAULT_WORKTREE_CONFIG;
253
+ if (!file) return { ...d };
254
+ return {
255
+ layout: file.layout ?? d.layout,
256
+ root: file.root ?? d.root,
257
+ branchPrefix: file.branchPrefix ?? d.branchPrefix,
258
+ baseBranch: file.baseBranch ?? d.baseBranch,
259
+ copyEnvFiles: file.copyEnvFiles ?? d.copyEnvFiles,
260
+ envPruneDirs: file.envPruneDirs ?? d.envPruneDirs,
261
+ // nullable fields: `null` is meaningful, so only override when defined
262
+ editorWorkspace: file.editorWorkspace !== void 0 ? file.editorWorkspace : d.editorWorkspace,
263
+ repos: file.repos !== void 0 ? file.repos : d.repos,
264
+ command: file.command !== void 0 ? file.command : d.command
265
+ };
266
+ }
267
+
211
268
  // src/config/loader.ts
212
269
  var ConfigError = class extends Error {
213
270
  constructor(message) {
@@ -218,6 +275,7 @@ var ConfigError = class extends Error {
218
275
  async function loadConfig(fs, opts = {}) {
219
276
  const sources = [{ kind: "default", status: "applied" }];
220
277
  const config = cloneDefaults();
278
+ let worktreesPartial = {};
221
279
  const layers = [
222
280
  { kind: "global", path: opts.globalPath },
223
281
  { kind: "project", path: opts.projectPath }
@@ -234,9 +292,12 @@ async function loadConfig(fs, opts = {}) {
234
292
  continue;
235
293
  }
236
294
  applyLayer(config, loaded.file);
295
+ if (loaded.file.worktrees) {
296
+ worktreesPartial = { ...worktreesPartial, ...loaded.file.worktrees };
297
+ }
237
298
  sources.push({ kind: layer.kind, path: layer.path, status: "applied" });
238
299
  }
239
- return { config, sources };
300
+ return { config, worktrees: resolveWorktreeConfig(worktreesPartial), sources };
240
301
  }
241
302
  function cloneDefaults() {
242
303
  const out = {};
@@ -1396,6 +1457,89 @@ function worktreeRelativePath(taskSlug, workerId) {
1396
1457
  return joinPath(WORKSPACE_DIR, "worktrees", slugifyForGit(taskSlug), slugifyForGit(workerId));
1397
1458
  }
1398
1459
 
1460
+ // src/worktree/multi-repo-plan.ts
1461
+ function safeTicket(ticket) {
1462
+ const s = ticket.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\.\.+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
1463
+ return s.length > 0 ? s : "ticket";
1464
+ }
1465
+ function buildTicketBranch(branchPrefix, ticket) {
1466
+ return `${branchPrefix}${safeTicket(ticket)}`;
1467
+ }
1468
+ function worktreePathFor(config, workspaceRoot, ticket, repo) {
1469
+ const t = safeTicket(ticket);
1470
+ if (config.layout === "nested") {
1471
+ return joinPath(workspaceRoot, repo, config.root, t);
1472
+ }
1473
+ return joinPath(workspaceRoot, config.root, t, repo);
1474
+ }
1475
+ function editorWorkspaceDir(config, workspaceRoot, ticket) {
1476
+ if (config.layout === "nested") return workspaceRoot;
1477
+ return joinPath(workspaceRoot, config.root, safeTicket(ticket));
1478
+ }
1479
+ function planWorktrees(input) {
1480
+ const { workspaceRoot, ticket, repos, config } = input;
1481
+ const branch = buildTicketBranch(config.branchPrefix, ticket);
1482
+ return repos.map((repo) => ({
1483
+ repo,
1484
+ repoPath: joinPath(workspaceRoot, repo),
1485
+ worktreePath: worktreePathFor(config, workspaceRoot, ticket, repo),
1486
+ branch
1487
+ }));
1488
+ }
1489
+ function editorWorkspaceContent(items, baseDir) {
1490
+ const prefix = `${baseDir}/`;
1491
+ const folders = items.map((item) => {
1492
+ const path = item.worktreePath.startsWith(prefix) ? item.worktreePath.slice(prefix.length) : item.worktreePath;
1493
+ return { path };
1494
+ });
1495
+ return `${JSON.stringify({ folders }, null, 2)}
1496
+ `;
1497
+ }
1498
+
1499
+ // src/worktree/editor-workspace.ts
1500
+ async function writeEditorWorkspace(fs, dir, ticket, items) {
1501
+ const path = joinPath(dir, `${safeTicket(ticket)}.code-workspace`);
1502
+ await fs.mkdir(dir);
1503
+ await fs.writeFile(path, editorWorkspaceContent(items, dir));
1504
+ return path;
1505
+ }
1506
+
1507
+ // src/worktree/env-copy.ts
1508
+ function isEnvFile(name) {
1509
+ if (name !== ".env" && !name.startsWith(".env.")) return false;
1510
+ if (/\.(example|sample)$/i.test(name)) return false;
1511
+ if (/\.bak(\.|$)/i.test(name)) return false;
1512
+ return true;
1513
+ }
1514
+ async function copyEnvFiles(fs, src, dst, pruneDirs) {
1515
+ const prune = new Set(pruneDirs);
1516
+ let count = 0;
1517
+ const walk = async (rel) => {
1518
+ const abs = rel.length > 0 ? joinPath(src, rel) : src;
1519
+ let entries;
1520
+ try {
1521
+ entries = await fs.readDir(abs);
1522
+ } catch {
1523
+ return;
1524
+ }
1525
+ for (const entry of entries) {
1526
+ const childRel = rel.length > 0 ? `${rel}/${entry.name}` : entry.name;
1527
+ if (entry.isDirectory) {
1528
+ if (prune.has(entry.name)) continue;
1529
+ await walk(childRel);
1530
+ } else if (isEnvFile(entry.name)) {
1531
+ const content = await fs.readFile(joinPath(src, childRel));
1532
+ const target = joinPath(dst, childRel);
1533
+ await fs.mkdir(dirname(target));
1534
+ await fs.writeFile(target, content);
1535
+ count++;
1536
+ }
1537
+ }
1538
+ };
1539
+ await walk("");
1540
+ return count;
1541
+ }
1542
+
1399
1543
  // src/worktree/git-detector.ts
1400
1544
  var GitDetector = class {
1401
1545
  constructor(process) {
@@ -1415,6 +1559,30 @@ var GitDetector = class {
1415
1559
  }
1416
1560
  };
1417
1561
 
1562
+ // src/worktree/git-repo-detector.ts
1563
+ async function hasGitDir(fs, dir) {
1564
+ try {
1565
+ const entries = await fs.readDir(dir);
1566
+ return entries.some((e) => e.name === ".git" && e.isDirectory);
1567
+ } catch {
1568
+ return false;
1569
+ }
1570
+ }
1571
+ async function detectGitRepos(fs, workspaceRoot) {
1572
+ let entries;
1573
+ try {
1574
+ entries = await fs.readDir(workspaceRoot);
1575
+ } catch {
1576
+ return [];
1577
+ }
1578
+ const repos = [];
1579
+ for (const entry of entries) {
1580
+ if (!entry.isDirectory) continue;
1581
+ if (await hasGitDir(fs, joinPath(workspaceRoot, entry.name))) repos.push(entry.name);
1582
+ }
1583
+ return repos.sort();
1584
+ }
1585
+
1418
1586
  // src/worktree/manager.ts
1419
1587
  var WorktreeError = class extends Error {
1420
1588
  name = "WorktreeError";
@@ -1482,11 +1650,130 @@ function parsePorcelain(output) {
1482
1650
  }
1483
1651
  return result;
1484
1652
  }
1653
+
1654
+ // src/worktree/multi-repo-manager.ts
1655
+ var MultiRepoWorktreeManager = class {
1656
+ constructor(process, fs) {
1657
+ this.process = process;
1658
+ this.fs = fs;
1659
+ }
1660
+ process;
1661
+ fs;
1662
+ async create(input) {
1663
+ const results = [];
1664
+ for (const item of input.items) {
1665
+ results.push(await this.createOne(item, input));
1666
+ }
1667
+ return results;
1668
+ }
1669
+ async createOne(item, input) {
1670
+ const base = { repo: item.repo, branch: item.branch, worktreePath: item.worktreePath };
1671
+ if (!await this.isGitRepo(item.repoPath)) {
1672
+ return { ...base, status: "skipped-not-git", envCopied: 0 };
1673
+ }
1674
+ if (await this.fs.exists(item.worktreePath)) {
1675
+ return { ...base, status: "skipped-exists", envCopied: 0 };
1676
+ }
1677
+ const status = await this.addWorktree(item, input.baseBranch);
1678
+ let envCopied = 0;
1679
+ if (input.copyEnvFiles) {
1680
+ envCopied = await copyEnvFiles(this.fs, item.repoPath, item.worktreePath, input.envPruneDirs);
1681
+ }
1682
+ return { ...base, status, envCopied };
1683
+ }
1684
+ async addWorktree(item, baseBranch) {
1685
+ const { repoPath, branch, worktreePath } = item;
1686
+ if (await this.branchExistsLocal(repoPath, branch)) {
1687
+ await this.git(repoPath, ["worktree", "add", worktreePath, branch]);
1688
+ return "reused-local";
1689
+ }
1690
+ if (await this.branchExistsRemote(repoPath, branch)) {
1691
+ await this.git(repoPath, ["fetch", "origin", `${branch}:${branch}`]);
1692
+ await this.git(repoPath, ["worktree", "add", worktreePath, branch]);
1693
+ return "reused-remote";
1694
+ }
1695
+ await this.process.exec("git", ["fetch", "origin", baseBranch], { cwd: repoPath });
1696
+ await this.git(repoPath, [
1697
+ "worktree",
1698
+ "add",
1699
+ "-b",
1700
+ branch,
1701
+ worktreePath,
1702
+ `origin/${baseBranch}`
1703
+ ]);
1704
+ return "created";
1705
+ }
1706
+ async cleanup(items) {
1707
+ const results = [];
1708
+ for (const item of items) {
1709
+ if (!await this.fs.exists(item.worktreePath)) {
1710
+ results.push({
1711
+ repo: item.repo,
1712
+ branch: item.branch,
1713
+ removed: false,
1714
+ mergeSuggestion: this.mergeSuggestion(item)
1715
+ });
1716
+ continue;
1717
+ }
1718
+ const result = await this.process.exec("git", ["worktree", "remove", item.worktreePath], {
1719
+ cwd: item.repoPath
1720
+ });
1721
+ if (result.exitCode !== 0) {
1722
+ throw new WorktreeError(
1723
+ `git worktree remove failed for ${item.repo} (uncommitted changes?): ${result.stderr.trim() || "unknown error"}`
1724
+ );
1725
+ }
1726
+ results.push({
1727
+ repo: item.repo,
1728
+ branch: item.branch,
1729
+ removed: true,
1730
+ mergeSuggestion: this.mergeSuggestion(item)
1731
+ });
1732
+ }
1733
+ return results;
1734
+ }
1735
+ mergeSuggestion(item) {
1736
+ return `git -C ${item.repoPath} merge --no-ff ${item.branch}`;
1737
+ }
1738
+ async isGitRepo(repoPath) {
1739
+ try {
1740
+ const entries = await this.fs.readDir(repoPath);
1741
+ return entries.some((e) => e.name === ".git" && e.isDirectory);
1742
+ } catch {
1743
+ return false;
1744
+ }
1745
+ }
1746
+ async branchExistsLocal(repoPath, branch) {
1747
+ const result = await this.process.exec(
1748
+ "git",
1749
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
1750
+ { cwd: repoPath }
1751
+ );
1752
+ return result.exitCode === 0;
1753
+ }
1754
+ async branchExistsRemote(repoPath, branch) {
1755
+ const result = await this.process.exec(
1756
+ "git",
1757
+ ["ls-remote", "--exit-code", "--heads", "origin", branch],
1758
+ { cwd: repoPath }
1759
+ );
1760
+ return result.exitCode === 0;
1761
+ }
1762
+ async git(repoPath, args) {
1763
+ const result = await this.process.exec("git", args, { cwd: repoPath });
1764
+ if (result.exitCode !== 0) {
1765
+ throw new WorktreeError(
1766
+ `git ${args.join(" ")} failed: ${result.stderr.trim() || "unknown error"}`
1767
+ );
1768
+ }
1769
+ }
1770
+ };
1485
1771
  export {
1486
1772
  AGENT_ROLES,
1487
1773
  ConfigError,
1488
1774
  ContextBuilder,
1489
1775
  DEFAULT_CONFIG,
1776
+ DEFAULT_WORKTREE_CONFIG,
1490
1777
  EFFORT_LEVELS,
1491
1778
  FakeProcess,
1492
1779
  FilesystemMemoryStore,
@@ -1494,6 +1781,7 @@ export {
1494
1781
  MEMORY_DIR,
1495
1782
  MODEL_CATALOG,
1496
1783
  MemoryFilesystem,
1784
+ MultiRepoWorktreeManager,
1497
1785
  ObsidianDetector,
1498
1786
  REASONING_PRIORITIES,
1499
1787
  ROLE_DESCRIPTIONS,
@@ -1509,9 +1797,14 @@ export {
1509
1797
  basename,
1510
1798
  buildBranchName,
1511
1799
  buildHint,
1800
+ buildTicketBranch,
1512
1801
  configFileSchema,
1802
+ copyEnvFiles,
1803
+ detectGitRepos,
1513
1804
  diffLines,
1514
1805
  dirname,
1806
+ editorWorkspaceContent,
1807
+ editorWorkspaceDir,
1515
1808
  extname,
1516
1809
  generatePlanTemplate,
1517
1810
  getModel,
@@ -1519,15 +1812,21 @@ export {
1519
1812
  loadConfig,
1520
1813
  modelsByProvider,
1521
1814
  parseChambaConfig,
1815
+ planWorktrees,
1522
1816
  renderNote,
1523
1817
  renderWorkspaceMarkdown,
1524
1818
  resolveEffort,
1525
1819
  resolveRole,
1820
+ resolveWorktreeConfig,
1821
+ safeTicket,
1526
1822
  slugify,
1527
1823
  slugifyForGit,
1528
1824
  suggestFilesLikelyTouched,
1529
1825
  suggestSubtasks,
1530
1826
  textsEqual,
1531
1827
  validatePlan,
1532
- worktreeRelativePath
1828
+ worktreeConfigSchema,
1829
+ worktreePathFor,
1830
+ worktreeRelativePath,
1831
+ writeEditorWorkspace
1533
1832
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chamba/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Core logic for chamba: workspace scanner, plan + heuristic reviewer, git worktrees, Obsidian, memory — no Node APIs, no LLM",
5
5
  "license": "MIT",
6
6
  "type": "module",