From db94f2954b59e3eceeab3e2794f905572b4f861e Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 10 Sep 2026 10:22:09 -0400 Subject: [PATCH 001/102] feat(bit-map): allow a component to own the workspace root (rootDir ".") --- components/legacy/bit-map/bit-map.spec.ts | 44 +++++++++++++ components/legacy/bit-map/bit-map.ts | 31 ++++++++- components/legacy/bit-map/component-map.ts | 66 +++++++++++++++---- components/legacy/bit-map/index.ts | 1 + .../consumer-component/consumer-component.ts | 6 +- e2e/harmony/add-harmony.e2e.ts | 26 ++++++++ scopes/component/tracker/add-components.ts | 29 ++++++-- 7 files changed, 181 insertions(+), 22 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 6cd51cfba1dc..44b847ac93fb 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -3,6 +3,7 @@ import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; import { logger } from '@teambit/legacy.logger'; import { BitMap } from './bit-map'; +import { WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; const getBitmapInstance = async () => { @@ -90,4 +91,47 @@ describe('BitMap', function () { ); }); }); + describe('workspace-root component', () => { + const rootComponentParams = { + componentId: ComponentID.fromObject({ name: 'ws-root' }, 'my-scope'), + files: [{ name: 'README.md', relativePath: 'README.md', test: false }], + mainFile: 'README.md', + defaultScope: 'my-scope', + rootDir: WORKSPACE_ROOT_DIR, + }; + const nestedComponentParams = { + componentId: ComponentID.fromObject({ name: 'comp1' }, 'my-scope'), + files: [{ name: 'index.js', relativePath: 'index.js', test: false }], + mainFile: 'index.js', + defaultScope: 'my-scope', + rootDir: 'packages/comp1', + }; + it('should allow a rootDir of "." to contain other components, in both add orders', async () => { + const rootFirst = await getBitmapInstance(); + rootFirst.addComponent(rootComponentParams); + expect(() => rootFirst.addComponent(nestedComponentParams)).to.not.throw(); + + const nestedFirst = await getBitmapInstance(); + nestedFirst.addComponent(nestedComponentParams); + expect(() => nestedFirst.addComponent(rootComponentParams)).to.not.throw(); + }); + it('should keep rejecting nesting between two non-root components', async () => { + const bitMap = await getBitmapInstance(); + bitMap.addComponent(nestedComponentParams); + expect(() => + bitMap.addComponent({ + ...nestedComponentParams, + componentId: ComponentID.fromObject({ name: 'comp2' }, 'my-scope'), + rootDir: 'packages/comp1/nested', + }) + ).to.throw(); + }); + it('getNestedRootDirs should return the nested components for the root, and nothing for a leaf', async () => { + const bitMap = await getBitmapInstance(); + bitMap.addComponent(rootComponentParams); + bitMap.addComponent(nestedComponentParams); + expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal(['packages/comp1']); + expect(bitMap.getNestedRootDirs('packages/comp1')).to.deep.equal([]); + }); + }); }); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index edce3070cd88..1dd2a8892ac6 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -28,7 +28,7 @@ import type { } from '@teambit/toolbox.path.path'; import { pathJoinLinux, pathNormalizeToLinux } from '@teambit/toolbox.path.path'; import type { ComponentMapFile, Config, PathChange } from './component-map'; -import { ComponentMap, getFilesByDir, getGitIgnoreHarmony } from './component-map'; +import { ComponentMap, getFilesByDir, getGitIgnoreHarmony, WORKSPACE_ROOT_DIR } from './component-map'; import { InvalidBitMap, MissingBitMapComponent } from './exceptions'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -99,15 +99,20 @@ export class BitMap { /** * in case the added component's root-dir is a parent-dir of other components - * or other component's root-dir is a parent root-dir of this component, throw an error + * or other component's root-dir is a parent root-dir of this component, throw an error. + * + * the workspace-root component (rootDir ".") is the one exception - it is allowed to contain + * other components. its file-set subtracts their root-dirs, so the two never claim the same file. */ private throwForExistingParentDir({ id, rootDir }: ComponentMap) { + if (rootDir === WORKSPACE_ROOT_DIR) return; const isParentDir = (parent: string, child: string) => { const relative = path.relative(parent, child); return relative && !relative.startsWith('..'); }; this.components.forEach((existingComponentMap) => { if (!existingComponentMap.rootDir) return; + if (existingComponentMap.rootDir === WORKSPACE_ROOT_DIR) return; if (isParentDir(existingComponentMap.rootDir, rootDir)) { throw new BitError( `unable to add "${id.toString()}", its rootDir ${rootDir} is inside ${ @@ -212,6 +217,21 @@ export class BitMap { delete componentsJson[LANE_KEY]; } + /** + * root-dirs of the components nested inside the given root-dir. their files belong to them, so + * the containing component must subtract them from its own file-set. + */ + getNestedRootDirs(rootDir: PathLinuxRelative): PathLinuxRelative[] { + return this.components + .map((componentMap) => componentMap.rootDir) + .filter((nested): nested is PathLinuxRelative => { + if (!nested || nested === rootDir) return false; + if (rootDir === WORKSPACE_ROOT_DIR) return nested !== WORKSPACE_ROOT_DIR; + const relative = path.relative(rootDir, nested); + return Boolean(relative) && !relative.startsWith('..'); + }); + } + async loadFiles() { const gitIgnore = await getGitIgnoreHarmony(this.projectRoot, this.ignoredFiles); await Promise.all( @@ -219,7 +239,12 @@ export class BitMap { const rootDir = componentMap.rootDir; if (!rootDir) return; try { - componentMap.files = await getFilesByDir(rootDir, this.projectRoot, gitIgnore); + componentMap.files = await getFilesByDir( + rootDir, + this.projectRoot, + gitIgnore, + this.getNestedRootDirs(rootDir) + ); componentMap.recentlyTracked = true; } catch (err: any) { componentMap.files = []; diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index d76d51b3fba2..d49f9544b444 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -3,7 +3,14 @@ import globby from 'globby'; import ignore from 'ignore'; import { pickBy, isNil, sortBy, isEmpty } from 'lodash'; import type { ComponentID } from '@teambit/component-id'; -import { BIT_MAP, Extensions, PACKAGE_JSON, IGNORE_ROOT_ONLY_LIST } from '@teambit/legacy.constants'; +import { + BIT_HIDDEN_DIR, + BIT_MAP, + DOT_GIT_DIR, + Extensions, + PACKAGE_JSON, + IGNORE_ROOT_ONLY_LIST, +} from '@teambit/legacy.constants'; import { ValidationError } from '@teambit/legacy.cli.error'; import { logger } from '@teambit/legacy.logger'; import { isValidPath } from '@teambit/legacy.utils'; @@ -26,6 +33,19 @@ import { IgnoredDirectory, ComponentNotFoundInPath } from '@teambit/legacy.consu export type Config = { [aspectId: string]: Record | '-' }; +/** + * rootDir of a component that owns the workspace root. such a component holds the files that no + * other component claims - e.g. the workspace config, CI config, README and license files. + * it is the only rootDir allowed to contain other components' root-dirs. + */ +export const WORKSPACE_ROOT_DIR = '.'; + +/** + * workspace-internal paths. relevant only for the workspace-root component - for any other + * component these live outside its root-dir and are never reached by the scan. + */ +const WORKSPACE_ROOT_IGNORE_LIST = [`${BIT_HIDDEN_DIR}/**`, BIT_MAP, `${DOT_GIT_DIR}/**`]; + export type ComponentMapFile = { relativePath: PathLinux; /** @@ -265,13 +285,17 @@ export class ComponentMap { * if the component dir has changed since the last tracking, re-scan the component-dir to get the * updated list of the files */ - async trackDirectoryChangesHarmony(consumerPath: PathOsBasedAbsolute, ignoredFiles?: string[]): Promise { + async trackDirectoryChangesHarmony( + consumerPath: PathOsBasedAbsolute, + ignoredFiles?: string[], + excludeDirs: PathLinux[] = [] + ): Promise { const trackDir = this.rootDir; if (!trackDir) { return; } const gitIgnore = await getGitIgnoreHarmony(consumerPath, ignoredFiles); - this.files = await getFilesByDir(trackDir, consumerPath, gitIgnore); + this.files = await getFilesByDir(trackDir, consumerPath, gitIgnore, excludeDirs); } updateNextVersion(nextVersion: NextVersion) { @@ -347,10 +371,8 @@ export class ComponentMap { if (!isValidPath(this.mainFile)) { throw new ValidationError(`${errorMessage} mainFile attribute ${this.mainFile} is invalid`); } - if (this.rootDir && !isValidPath(this.rootDir)) { - throw new ValidationError(`${errorMessage} rootDir attribute ${this.rootDir} is invalid`); - } - if (this.rootDir && this.rootDir === '.') { + // "." is valid - it marks the component that owns the workspace root. see WORKSPACE_ROOT_DIR. + if (this.rootDir && this.rootDir !== WORKSPACE_ROOT_DIR && !isValidPath(this.rootDir)) { throw new ValidationError(`${errorMessage} rootDir attribute ${this.rootDir} is invalid`); } if (this.nextVersion && !this.nextVersion.version) { @@ -382,19 +404,39 @@ if you renamed the mainFile, please re-add the component with the "--main" flag } } -export async function getFilesByDir(dir: string, consumerPath: string, gitIgnore: any): Promise { - const matches = await globby(dir, { +/** + * scan a component's root-dir for its files. + * + * `excludeDirs` holds the root-dirs of components nested inside `dir`. their files belong to the + * nested component, not to this one. this is what makes a workspace-root component (rootDir ".") + * possible: it owns every file that no other component claims. + */ +export async function getFilesByDir( + dir: string, + consumerPath: string, + gitIgnore: any, + excludeDirs: PathLinux[] = [] +): Promise { + const isWorkspaceRoot = dir === WORKSPACE_ROOT_DIR; + const matches = await globby(isWorkspaceRoot ? '**' : dir, { cwd: consumerPath, dot: true, onlyFiles: true, // must ignore node_modules at this stage, although we check for gitignore later on. // otherwise, it hurts performance dramatically for components that have node_modules in the comp-dir. - ignore: [`${dir}/node_modules/`], + ignore: [ + isWorkspaceRoot ? '**/node_modules/**' : `${dir}/node_modules/`, + ...(isWorkspaceRoot ? WORKSPACE_ROOT_IGNORE_LIST : []), + ...excludeDirs.map((excludeDir) => `${excludeDir}/**`), + ], }); if (!matches.length) throw new ComponentNotFoundInPath(dir); const filteredMatches: string[] = gitIgnore.filter(matches); - // the path is relative to consumer. remove the rootDir. - const relativePathsLinux = filteredMatches.map((match) => pathNormalizeToLinux(match).replace(`${dir}/`, '')); + // the path is relative to consumer. remove the rootDir. for the workspace-root component the + // paths are already relative to the consumer, so there is nothing to strip. + const relativePathsLinux = filteredMatches.map((match) => + isWorkspaceRoot ? pathNormalizeToLinux(match) : pathNormalizeToLinux(match).replace(`${dir}/`, '') + ); const filteredByIgnoredFromRoot = relativePathsLinux.filter((match) => !IGNORE_ROOT_ONLY_LIST.includes(match)); const bitOrGitIgnore = filteredByIgnoredFromRoot.includes(BIT_IGNORE) ? await getBitIgnoreFile(dir) diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 748bd90ff05d..c5fdd7f5d1c9 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -14,4 +14,5 @@ export { Config, getIgnoreListHarmony, NextVersion, + WORKSPACE_ROOT_DIR, } from './component-map'; diff --git a/components/legacy/consumer-component/consumer-component.ts b/components/legacy/consumer-component/consumer-component.ts index 749f6b1215da..10ed97a1f2c0 100644 --- a/components/legacy/consumer-component/consumer-component.ts +++ b/components/legacy/consumer-component/consumer-component.ts @@ -600,7 +600,11 @@ async function getLoadedFiles( logger.error(`rethrowing an error of ${componentMap.noFilesError.message}`); throw componentMap.noFilesError; } - await componentMap.trackDirectoryChangesHarmony(consumer.getPath(), consumer.config.ignoredFiles); + await componentMap.trackDirectoryChangesHarmony( + consumer.getPath(), + consumer.config.ignoredFiles, + consumer.bitMap.getNestedRootDirs(componentMap.getRootDir()) + ); const sourceFiles = componentMap.files.map((file) => { const filePath = path.join(bitDir, file.relativePath); const sourceFile = SourceFile.load(filePath, bitDir, consumer.getPath(), { test: file.test || false }); diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 3f24afacc7d7..bdcf0026eeb5 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -4,6 +4,7 @@ import { ParentDirTracked, AddingIndividualFiles } from '@teambit/tracker'; import { Helper } from '@teambit/legacy.e2e-helper'; import chaiFs from 'chai-fs'; chai.use(chaiFs); +const { expect } = chai; describe('add command on Harmony', function () { this.timeout(0); @@ -37,4 +38,29 @@ describe('add command on Harmony', function () { helper.general.expectToThrow(cmd, error); }); }); + describe('adding the workspace root as a component', () => { + let rootFiles: string[]; + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fixtures.populateComponents(1); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + // written after tracking. the root file-set is re-scanned, not frozen at add-time. + helper.fs.outputFile('LICENSE', 'MIT\n'); + rootFiles = helper.command.showComponentParsed('ws-root').files.map((file) => file.relativePath); + }); + it('should save "." as the rootDir', () => { + expect(helper.bitMap.read()['ws-root'].rootDir).to.equal('.'); + }); + it('should own the root files, including files added after it was tracked', () => { + expect(rootFiles).to.include('README.md'); + expect(rootFiles).to.include('LICENSE'); + }); + it('should not claim the files of the component nested inside it', () => { + expect(rootFiles.some((file) => file.startsWith('comp1/'))).to.be.false; + }); + it('should not claim bit internal files', () => { + expect(rootFiles.some((file) => file.startsWith('.bit'))).to.be.false; + }); + }); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 649d17a8f107..29283d210006 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -11,7 +11,7 @@ import type { BitIdStr } from '@teambit/legacy-bit-id'; import { BitId } from '@teambit/legacy-bit-id'; import { PACKAGE_JSON, VERSION_DELIMITER, AUTO_GENERATED_STAMP } from '@teambit/legacy.constants'; import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; -import { ComponentMap, getIgnoreListHarmony, MissingMainFile } from '@teambit/legacy.bit-map'; +import { ComponentMap, getIgnoreListHarmony, MissingMainFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { DuplicateIds, EmptyDirectory, ExcludedMainFile, MainFileIsDir, NoFiles, PathsNotExist } from './exceptions'; import { AddingIndividualFiles } from './exceptions/adding-individual-files'; import MissingMainFileMultipleComponents from './exceptions/missing-main-file-multiple-components'; @@ -291,9 +291,13 @@ export default class AddComponents { if (this.trackDirFeature) throw new Error('track dir should not calculate the rootDir'); if (foundComponentFromBitMap) return foundComponentFromBitMap.rootDir; if (!trackDir) throw new Error(`addOrUpdateComponentInBitMap expect to have trackDir for non-legacy workspace`); - const fileNotInsideTrackDir = componentFiles.find( - (file) => !pathNormalizeToLinux(file.relativePath).startsWith(`${pathNormalizeToLinux(trackDir)}/`) - ); + // every file in the workspace is inside the workspace root, so there is nothing to check. + const fileNotInsideTrackDir = + trackDir === WORKSPACE_ROOT_DIR + ? undefined + : componentFiles.find( + (file) => !pathNormalizeToLinux(file.relativePath).startsWith(`${pathNormalizeToLinux(trackDir)}/`) + ); if (fileNotInsideTrackDir) { // we check for this error before. however, it's possible that a user have one trackDir // and another dir for the tests. @@ -492,13 +496,23 @@ you can add the directory these files are located at and it'll change the root d if (this.id) { finalBitId = this._getIdAccordingToExistingComponent(this.id); } - const relativeComponentPath = this.consumer.getPathRelativeToConsumer(componentPath); + // when the tracked dir is the workspace root itself, the relative path is empty. normalize it + // to "." so it is a real rootDir rather than a falsy one. + const relativeComponentPath = this.consumer.getPathRelativeToConsumer(componentPath) || WORKSPACE_ROOT_DIR; this._throwForOutsideConsumer(relativeComponentPath); throwForExistingParentDir(this.bitMap, relativeComponentPath); - const matches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { + const allMatches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { cwd: this.consumer.getPath(), nodir: true, }); + // files of components nested inside this dir belong to them, not to the component being added. + const nestedRootDirs = this.bitMap.getNestedRootDirs(relativeComponentPath); + const matches = nestedRootDirs.length + ? allMatches.filter((match: PathOsBased) => { + const linuxMatch = pathNormalizeToLinux(match); + return !nestedRootDirs.some((nestedRootDir) => linuxMatch.startsWith(`${nestedRootDir}/`)); + }) + : allMatches; if (!matches.length) throw new EmptyDirectory(componentPath); @@ -656,6 +670,9 @@ function throwForExistingParentDir(bitMap: BitMap, relativeToConsumerPath: PathO }; bitMap.components.forEach((componentMap) => { if (!componentMap.rootDir) return; + // the workspace-root component contains every other component by design. it subtracts their + // root-dirs from its own file-set, so tracking a dir inside it is not a conflict. + if (componentMap.rootDir === WORKSPACE_ROOT_DIR) return; if (isParentDir(componentMap.rootDir)) { throw new ParentDirTracked( componentMap.rootDir, From f0ca11356c7d4b94d3d5c259e75a38c23800da24 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 10 Sep 2026 10:59:21 -0400 Subject: [PATCH 002/102] feat(bit-map): track .bitmap in the workspace-root component --- components/legacy/bit-map/bit-map.spec.ts | 38 ++++++++++++++++- components/legacy/bit-map/bit-map.ts | 24 +++++++++++ components/legacy/bit-map/component-map.ts | 11 +++-- components/legacy/bit-map/index.ts | 1 + .../consumer-component/consumer-component.ts | 14 ++++++- e2e/harmony/add-harmony.e2e.ts | 42 ++++++++++++++++++- scopes/component/tracker/add-components.ts | 9 +++- 7 files changed, 131 insertions(+), 8 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 44b847ac93fb..77913cd4804a 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; import { logger } from '@teambit/legacy.logger'; -import { BitMap } from './bit-map'; +import { BitMap, normalizeBitmapContentForVersioning } from './bit-map'; import { WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -134,4 +134,40 @@ describe('BitMap', function () { expect(bitMap.getNestedRootDirs('packages/comp1')).to.deep.equal([]); }); }); + describe('normalizeBitmapContentForVersioning', () => { + const rawBitmap = JSON.stringify( + { + comp1: { + name: 'comp1', + scope: 'my-scope', + version: '0a14284ddaadde623d5c11f5511594485a14b3c8', + defaultScope: 'my-org.demo', + mainFile: 'index.ts', + rootDir: 'comp1', + }, + '$schema-version': '17.0.0', + }, + null, + 4 + ); + let normalized: string; + let parsed: Record; + before(() => { + normalized = normalizeBitmapContentForVersioning(rawBitmap); + parsed = JSON.parse(normalized.slice(normalized.indexOf('{'))); + }); + it('should empty the fields that change on every snap and export', () => { + expect(parsed.comp1.version).to.equal(''); + expect(parsed.comp1.scope).to.equal(''); + }); + it('should keep the durable map intact', () => { + expect(parsed.comp1.rootDir).to.equal('comp1'); + expect(parsed.comp1.mainFile).to.equal('index.ts'); + expect(parsed.comp1.defaultScope).to.equal('my-org.demo'); + expect(parsed['$schema-version']).to.equal('17.0.0'); + }); + it('should be idempotent, otherwise the root component would never converge', () => { + expect(normalizeBitmapContentForVersioning(normalized)).to.equal(normalized); + }); + }); }); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 1dd2a8892ac6..d61c51e4414f 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1044,6 +1044,30 @@ type OutputFileParams = { prefixMessage?: string; }; +/** + * the workspace-root component tracks `.bitmap` so a git-free workspace can be restored from the + * scope. the `version` and `scope` of every entry change on each snap and export - including the + * root component's own entry - so versioning them verbatim would leave that component modified + * immediately after every snap, forever, and never converge. + * + * only the durable part of the map is versioned: which components exist and where they live. the + * versions themselves are restored from the component heads on import, which is the correct source + * for them anyway. + */ +export function normalizeBitmapContentForVersioning(rawContent: string): string { + const parsed = json.parse(rawContent, undefined, true) as Record | undefined; + if (!parsed) return rawContent; + Object.keys(parsed).forEach((key) => { + const entry = parsed[key]; + // component entries are objects with a mainFile. skips the schema field (a string) and the + // lanes key (an object without a mainFile). + if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !('mainFile' in entry)) return; + if ('version' in entry) entry.version = ''; + if ('scope' in entry) entry.scope = ''; + }); + return `${AUTO_GENERATED_MSG}${BITMAP_PREFIX_MESSAGE}${JSON.stringify(parsed, null, 4)}`; +} + async function outputFile({ filePath, content, diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index d49f9544b444..db21afafedab 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -41,10 +41,15 @@ export type Config = { [aspectId: string]: Record | '-' }; export const WORKSPACE_ROOT_DIR = '.'; /** - * workspace-internal paths. relevant only for the workspace-root component - for any other - * component these live outside its root-dir and are never reached by the scan. + * paths the workspace-root component must never own. relevant only for that component - for any + * other component these live outside its root-dir and are never reached by the scan. + * + * note that `.bitmap` is deliberately NOT here. it is the map of the workspace and a git-free + * workspace has to be able to restore it, so the root component tracks it like any other file. + * `.bit` (the local object store) and `.git` are the outputs of versioning, not sources, so they + * stay excluded. */ -const WORKSPACE_ROOT_IGNORE_LIST = [`${BIT_HIDDEN_DIR}/**`, BIT_MAP, `${DOT_GIT_DIR}/**`]; +const WORKSPACE_ROOT_IGNORE_LIST = [`${BIT_HIDDEN_DIR}/**`, `${DOT_GIT_DIR}/**`]; export type ComponentMapFile = { relativePath: PathLinux; diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index c5fdd7f5d1c9..2208bd7d6d53 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -5,6 +5,7 @@ export { CURRENT_BITMAP_SCHEMA, SCHEMA_FIELD, LANE_KEY, + normalizeBitmapContentForVersioning, } from './bit-map'; export { MissingBitMapComponent, MissingMainFile, InvalidBitMap } from './exceptions'; export { diff --git a/components/legacy/consumer-component/consumer-component.ts b/components/legacy/consumer-component/consumer-component.ts index 10ed97a1f2c0..9eedb6119ac0 100644 --- a/components/legacy/consumer-component/consumer-component.ts +++ b/components/legacy/consumer-component/consumer-component.ts @@ -7,7 +7,13 @@ import { IssuesList } from '@teambit/component-issues'; import { BitId } from '@teambit/legacy-bit-id'; import { BitError } from '@teambit/bit-error'; import type { BuildStatus } from '@teambit/legacy.constants'; -import { getCloudDomain, BIT_WORKSPACE_TMP_DIRNAME, DEFAULT_LANGUAGE, Extensions } from '@teambit/legacy.constants'; +import { + getCloudDomain, + BIT_MAP, + BIT_WORKSPACE_TMP_DIRNAME, + DEFAULT_LANGUAGE, + Extensions, +} from '@teambit/legacy.constants'; import type { Doclet } from '@teambit/semantics.doc-parser'; import { parser as docsParser } from '@teambit/semantics.doc-parser'; import { logger } from '@teambit/legacy.logger'; @@ -16,6 +22,7 @@ import type { PathLinux, PathOsBased, PathOsBasedRelative } from '@teambit/toolb import { pathNormalizeToLinux } from '@teambit/toolbox.path.path'; import { sha1 } from '@teambit/toolbox.crypto.sha1'; import type { ComponentMap } from '@teambit/legacy.bit-map'; +import { normalizeBitmapContentForVersioning, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { IgnoredDirectory } from './exceptions/ignored-directory'; import type { Dist, PackageJsonFile, DataToPersist } from '@teambit/component.sources'; import { License, SourceFile } from '@teambit/component.sources'; @@ -608,6 +615,11 @@ async function getLoadedFiles( const sourceFiles = componentMap.files.map((file) => { const filePath = path.join(bitDir, file.relativePath); const sourceFile = SourceFile.load(filePath, bitDir, consumer.getPath(), { test: file.test || false }); + // the workspace-root component owns .bitmap. strip the fields that change on every snap so the + // component converges instead of being modified again the moment it is snapped. + if (componentMap.rootDir === WORKSPACE_ROOT_DIR && file.relativePath === BIT_MAP) { + sourceFile.contents = Buffer.from(normalizeBitmapContentForVersioning(sourceFile.contents.toString())); + } return sourceFile; }); const filePaths = componentMap.getAllFilesPaths(); diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index bdcf0026eeb5..bd1ecb9993b5 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -59,8 +59,46 @@ describe('add command on Harmony', function () { it('should not claim the files of the component nested inside it', () => { expect(rootFiles.some((file) => file.startsWith('comp1/'))).to.be.false; }); - it('should not claim bit internal files', () => { - expect(rootFiles.some((file) => file.startsWith('.bit'))).to.be.false; + it('should track .bitmap, so a git-free workspace can be restored from the scope', () => { + expect(rootFiles).to.include('.bitmap'); + }); + it('should not claim the local scope directory', () => { + expect(rootFiles.some((file) => file.startsWith('.bit/'))).to.be.false; + }); + }); + describe('workspace-root component and .bitmap', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + // deliberately not using populateComponents - it writes an app.js at the workspace root that + // requires './comp1'. the root component would then own a file with a relative dependency on + // another component, which bit rejects regardless of this feature. + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + }); + it('should not be modified right after snapping, despite tracking .bitmap', () => { + // .bitmap is rewritten with the new versions on every snap - including the root component's + // own entry. without normalizing those fields out, the component would never converge. + expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(0); + }); + describe('adding a new component inside the workspace root', () => { + before(() => { + helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); + helper.command.addComponent('comp2', { i: 'comp2' }); + }); + it('should let the new component take the files from the root component', () => { + const rootFiles = helper.command.showComponentParsed('ws-root').files.map((file) => file.relativePath); + expect(rootFiles.some((file) => file.startsWith('comp2/'))).to.be.false; + }); + it('should mark the root component as modified, because the map changed', () => { + expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(1); + }); + it('should converge again after snapping the map change', () => { + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(0); + }); }); }); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 29283d210006..3e021087434d 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -241,7 +241,14 @@ export default class AddComponents { } const caseSensitive = false; const existingIdOfFile = this.bitMap.getComponentIdByPath(file.relativePath, caseSensitive); - const idOfFileIsDifferent = existingIdOfFile && !existingIdOfFile.isEqual(parsedBitId); + // the workspace-root component owns every file no other component claims, so it "owns" this + // file only until a more specific component is added for it. the component being added wins, + // and the root subtracts the new root-dir from its own file-set on the next scan. + const existingIsWorkspaceRoot = existingIdOfFile + ? this.bitMap.getComponentIfExist(existingIdOfFile, { ignoreVersion: true })?.rootDir === WORKSPACE_ROOT_DIR + : false; + const idOfFileIsDifferent = + existingIdOfFile && !existingIdOfFile.isEqual(parsedBitId) && !existingIsWorkspaceRoot; if (idOfFileIsDifferent) { // not imported component file but exists in bitmap // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! From d1663859f8b8a43c26a45087cad7204c0df0584c Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 10 Sep 2026 11:16:23 -0400 Subject: [PATCH 003/102] feat(issues): skip env/compiler-derived issues for the workspace-root component --- e2e/harmony/add-harmony.e2e.ts | 30 ++++++++++++++++ .../component/issues/issues.main.runtime.ts | 35 +++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index bd1ecb9993b5..619280c836ea 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -101,4 +101,34 @@ describe('add command on Harmony', function () { }); }); }); + describe('component issues on the workspace-root component', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + }); + it('should not report issues that only apply to a component with an env and a compiler', () => { + const withIssues = helper.command + .statusJson() + .componentsWithIssues.map((comp) => comp.id) + .filter((id: string) => id.includes('ws-root')); + expect(withIssues).to.have.lengthOf(0); + }); + it('should snap without needing --ignore-issues', () => { + expect(() => helper.command.snapComponentWithoutBuild('ws-root')).to.not.throw(); + }); + describe('when a root file has a relative import into a component', () => { + before(() => { + helper.fs.outputFile('app.js', "const comp1 = require('./comp1');\n"); + }); + it('should still report the relative-import issue', () => { + // this one is NOT irrelevant to the root component. suppressing it would replace an + // actionable issue with a "this error should have never happened" failure on saving the + // Version object. + expect(helper.command.getAllIssuesFromStatus()).to.include('RelativeComponentsAuthored'); + }); + }); + }); }); diff --git a/scopes/component/issues/issues.main.runtime.ts b/scopes/component/issues/issues.main.runtime.ts index 66f0d602f85e..0baf2fa72c5a 100644 --- a/scopes/component/issues/issues.main.runtime.ts +++ b/scopes/component/issues/issues.main.runtime.ts @@ -1,7 +1,9 @@ import type { CLIMain } from '@teambit/cli'; import { CLIAspect, MainRuntime } from '@teambit/cli'; import type { Component } from '@teambit/component'; +import type { IssuesNames } from '@teambit/component-issues'; import { IssuesClasses, IssuesList } from '@teambit/component-issues'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { SlotRegistry } from '@teambit/harmony'; import { Slot } from '@teambit/harmony'; import pMapSeries from 'p-map-series'; @@ -13,6 +15,31 @@ export type IssuesConfig = { ignoreIssues: string[]; }; +/** + * the workspace-root component (rootDir ".") holds the files that no other component claims - + * workspace config, CI config, .bitmap, README, LICENSE. it has no env toolchain, no compiler, and + * nothing imports it as a package. + * + * only the issues that misfire *because of those three properties* are ignored. the rest are kept + * on purpose: the dependency-related issues simply never fire for a component whose files hold no + * imports, and when they do fire they are reporting something real. ignoring them is actively + * harmful - suppressing RelativeComponents, for instance, replaces an actionable issue with a + * "this error should have never happened" failure when the Version object is saved. + */ +const ISSUES_IRRELEVANT_TO_WORKSPACE_ROOT: IssuesNames[] = [ + // the env's dependency policy (@types/node and friends) is not installed for a component that + // has no env toolchain. + 'MissingManuallyConfiguredPackages', + // there is no compiler, so there is never any dist output. + 'MissingDists', + // nothing resolves this component as a package, so it needs no node_modules link. + 'MissingLinksFromNodeModulesToSrc', +]; + +function isWorkspaceRootComponent(component: Component): boolean { + return component.state._consumer?.componentMap?.rootDir === WORKSPACE_ROOT_DIR; +} + export type AddComponentsIssues = (components: Component[], issuesToIgnore: string[]) => Promise; export type AddComponentsIssuesSlot = SlotRegistry; @@ -30,9 +57,11 @@ export class IssuesMain { } getIssuesToIgnorePerComponent(component: Component): string[] { - const issuesToIgnore = component.state.aspects.get(IssuesAspect.id)?.config.ignoreIssues; - if (!issuesToIgnore) return []; - this.validateIssueNames(issuesToIgnore); + const issuesToIgnore: string[] = component.state.aspects.get(IssuesAspect.id)?.config.ignoreIssues || []; + if (issuesToIgnore.length) this.validateIssueNames(issuesToIgnore); + if (isWorkspaceRootComponent(component)) { + return [...issuesToIgnore, ...ISSUES_IRRELEVANT_TO_WORKSPACE_ROOT]; + } return issuesToIgnore; } From 016b3c4e6c32b5a6e9ee6f509781313c502e4234 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 10 Sep 2026 11:51:15 -0400 Subject: [PATCH 004/102] feat(workspace-root): use the empty env, and keep it out of install and write paths the workspace-root component (rootDir ".") is a bag of the workspace's own config files. three things treated it as a regular source component: - env: it defaulted to the regular default env, giving it a compiler and a dependency policy it can never use. it now defaults to the empty env. an env set explicitly on it still wins. - install: its dir is the workspace root, so handing it to the package manager collided with the root project - pnpm resolved it to an empty "file:" spec and failed to build the lockfile, breaking "bit install" entirely. - write: importing it into another workspace wrote a .bitmap into a sub-directory, silently turning that dir into a broken nested workspace, and checking out an earlier version of it crashed on a non-BitError. the empty env removes the compiler-derived issue structurally, so the issue-ignore list added for this component is no longer needed and is reverted. --- e2e/harmony/add-harmony.e2e.ts | 64 +++++++++++++++---- .../component-writer/component-writer.ts | 27 ++++++-- .../component/issues/issues.main.runtime.ts | 35 +--------- scopes/envs/envs/environments.main.runtime.ts | 30 ++++++++- .../workspace/install/install.main.runtime.ts | 9 ++- 5 files changed, 113 insertions(+), 52 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 619280c836ea..93a6557b6710 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -101,7 +101,39 @@ describe('add command on Harmony', function () { }); }); }); - describe('component issues on the workspace-root component', () => { + describe('writing the workspace-root component to the filesystem', () => { + let firstSnap: string; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + firstSnap = helper.command.getHead('ws-root'); + helper.fs.outputFile('README.md', '# workspace root v2\n'); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + }); + it('should check out an earlier version of it without throwing', () => { + // the writer used to hard-fail on a rootDir of "." with a non-BitError. + expect(() => helper.command.checkoutVersion(firstSnap, 'ws-root', '-x')).to.not.throw(); + }); + describe('importing it into another workspace', () => { + before(() => { + helper.command.export(); + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.command.importComponentWithoutInstall('ws-root'); + }); + it('should not write a .bitmap outside the workspace root', () => { + // a .bitmap inside a component dir turns that dir into a broken nested workspace - every + // bit command run from there operates on it instead of on the real workspace. + const bitmaps = helper.fs.getConsumerFiles('.bitmap', true, false); + expect(bitmaps).to.deep.equal([path.normalize('.bitmap')]); + }); + }); + }); + describe('env of the workspace-root component', () => { before(() => { helper.scopeHelper.reInitWorkspace(); helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); @@ -109,24 +141,30 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# workspace root\n'); helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); }); - it('should not report issues that only apply to a component with an env and a compiler', () => { - const withIssues = helper.command - .statusJson() - .componentsWithIssues.map((comp) => comp.id) - .filter((id: string) => id.includes('ws-root')); - expect(withIssues).to.have.lengthOf(0); + it('should default to the empty env, not to the regular default env', () => { + // it is a bag of the workspace's own config files - nothing compiles it, tests it, or + // imports it as a package. the regular default env would give it a toolchain it can't use. + expect(helper.env.getComponentEnv('ws-root')).to.equal('teambit.harmony/empty-env'); + }); + it('should leave the env of a regular component alone', () => { + expect(helper.env.getComponentEnv('comp1')).to.equal('teambit.harmony/node'); }); - it('should snap without needing --ignore-issues', () => { - expect(() => helper.command.snapComponentWithoutBuild('ws-root')).to.not.throw(); + it('should not report compiler-derived issues, while a regular component still does', () => { + const issuesOf = (name: string): string[] => { + const comp = helper.command.statusJson().componentsWithIssues.find((c) => c.id.includes(name)); + return comp ? comp.issues.map((issue) => issue.type) : []; + }; + // the empty env has no compiler, so "missing dists" can never apply to the root component. + expect(issuesOf('ws-root')).to.not.include('MissingDists'); + expect(issuesOf('comp1')).to.include('MissingDists'); }); describe('when a root file has a relative import into a component', () => { before(() => { helper.fs.outputFile('app.js', "const comp1 = require('./comp1');\n"); }); - it('should still report the relative-import issue', () => { - // this one is NOT irrelevant to the root component. suppressing it would replace an - // actionable issue with a "this error should have never happened" failure on saving the - // Version object. + it('should report the relative-import issue like any other component', () => { + // the root component is not exempt from this one. without it, the user gets an + // "this error should have never happened" failure when the Version object is saved. expect(helper.command.getAllIssuesFromStatus()).to.include('RelativeComponentsAuthored'); }); }); diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index c71864e417f0..428ed99559d0 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -3,6 +3,8 @@ import type { Scope } from '@teambit/legacy.scope'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { BitMap, ComponentMap } from '@teambit/legacy.bit-map'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { BIT_MAP } from '@teambit/legacy.constants'; import type { ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -104,8 +106,9 @@ export default class ComponentWriter { if (this.deleteBitDirContent) { this.component.dataToPersist.removePath(new RemovePath(this.writeToPath)); } - this.component.files.forEach((file) => (file.override = this.override)); - this.component.files.map((file) => this.component.dataToPersist.addFile(file)); + const filesToWrite = this.component.files.filter((file) => !this.shouldSkipWritingFile(file)); + filesToWrite.forEach((file) => (file.override = this.override)); + filesToWrite.map((file) => this.component.dataToPersist.addFile(file)); if (this.component.license && this.component.license.contents) { this.component.license.updatePaths({ newBase: this.writeToPath }); @@ -118,9 +121,25 @@ export default class ComponentWriter { } } + /** + * `.bitmap` is only ever a file of the workspace-root component, and it is never safe to write: + * writing it into a sub-directory (importing a workspace-root component into another workspace) + * creates a broken nested workspace there, and writing it onto the workspace root would clobber + * the live map with a stale one - while the very operation doing the write is mutating it. + * the rest of the component's files are written normally. + */ + private shouldSkipWritingFile(file: { relative: string }): boolean { + return pathNormalizeToLinux(file.relative) === BIT_MAP; + } + async addComponentToBitMap(rootDir: string): Promise { - if (rootDir === '.') { - throw new Error('addComponentToBitMap: rootDir cannot be "."'); + // "." is a valid rootDir only for the component that owns this workspace's root. it is never a + // valid *target* to write some other component into. + if (rootDir === WORKSPACE_ROOT_DIR && this.existingComponentMap?.rootDir !== WORKSPACE_ROOT_DIR) { + throw new BitError( + `unable to write "${this.component.id.toString()}" to the workspace root. +the workspace root is owned by the workspace itself, a component can only be written into its own directory` + ); } const filesForBitMap = this.component.files.map((file) => { return { name: file.basename, relativePath: pathNormalizeToLinux(file.relative), test: file.test }; diff --git a/scopes/component/issues/issues.main.runtime.ts b/scopes/component/issues/issues.main.runtime.ts index 0baf2fa72c5a..66f0d602f85e 100644 --- a/scopes/component/issues/issues.main.runtime.ts +++ b/scopes/component/issues/issues.main.runtime.ts @@ -1,9 +1,7 @@ import type { CLIMain } from '@teambit/cli'; import { CLIAspect, MainRuntime } from '@teambit/cli'; import type { Component } from '@teambit/component'; -import type { IssuesNames } from '@teambit/component-issues'; import { IssuesClasses, IssuesList } from '@teambit/component-issues'; -import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { SlotRegistry } from '@teambit/harmony'; import { Slot } from '@teambit/harmony'; import pMapSeries from 'p-map-series'; @@ -15,31 +13,6 @@ export type IssuesConfig = { ignoreIssues: string[]; }; -/** - * the workspace-root component (rootDir ".") holds the files that no other component claims - - * workspace config, CI config, .bitmap, README, LICENSE. it has no env toolchain, no compiler, and - * nothing imports it as a package. - * - * only the issues that misfire *because of those three properties* are ignored. the rest are kept - * on purpose: the dependency-related issues simply never fire for a component whose files hold no - * imports, and when they do fire they are reporting something real. ignoring them is actively - * harmful - suppressing RelativeComponents, for instance, replaces an actionable issue with a - * "this error should have never happened" failure when the Version object is saved. - */ -const ISSUES_IRRELEVANT_TO_WORKSPACE_ROOT: IssuesNames[] = [ - // the env's dependency policy (@types/node and friends) is not installed for a component that - // has no env toolchain. - 'MissingManuallyConfiguredPackages', - // there is no compiler, so there is never any dist output. - 'MissingDists', - // nothing resolves this component as a package, so it needs no node_modules link. - 'MissingLinksFromNodeModulesToSrc', -]; - -function isWorkspaceRootComponent(component: Component): boolean { - return component.state._consumer?.componentMap?.rootDir === WORKSPACE_ROOT_DIR; -} - export type AddComponentsIssues = (components: Component[], issuesToIgnore: string[]) => Promise; export type AddComponentsIssuesSlot = SlotRegistry; @@ -57,11 +30,9 @@ export class IssuesMain { } getIssuesToIgnorePerComponent(component: Component): string[] { - const issuesToIgnore: string[] = component.state.aspects.get(IssuesAspect.id)?.config.ignoreIssues || []; - if (issuesToIgnore.length) this.validateIssueNames(issuesToIgnore); - if (isWorkspaceRootComponent(component)) { - return [...issuesToIgnore, ...ISSUES_IRRELEVANT_TO_WORKSPACE_ROOT]; - } + const issuesToIgnore = component.state.aspects.get(IssuesAspect.id)?.config.ignoreIssues; + if (!issuesToIgnore) return []; + this.validateIssueNames(issuesToIgnore); return issuesToIgnore; } diff --git a/scopes/envs/envs/environments.main.runtime.ts b/scopes/envs/envs/environments.main.runtime.ts index 368765bc9e69..23b87bab4e3f 100644 --- a/scopes/envs/envs/environments.main.runtime.ts +++ b/scopes/envs/envs/environments.main.runtime.ts @@ -26,6 +26,7 @@ import { head, uniq } from 'lodash'; import type { WorkerMain } from '@teambit/worker'; import { WorkerAspect } from '@teambit/worker'; import { ComponentID } from '@teambit/component-id'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { EnvService } from './services'; import type { Environment } from './environment'; import { EnvsAspect } from './environments.aspect'; @@ -105,6 +106,17 @@ export type Descriptor = RegularCompDescriptor | EnvCompDescriptor; export const DEFAULT_ENV = 'teambit.harmony/node'; +/** + * the workspace-root component (rootDir ".") owns the files no other component claims - workspace + * config, CI config, .bitmap, README, LICENSE. it is a bag of config files, not a source component: + * nothing compiles it, nothing tests it, and nothing imports it as a package. defaulting it to the + * regular default env gives it a compiler and a dependency policy it can never satisfy. + * + * hardcoded rather than imported from the aspect, same as DEFAULT_ENV above - empty-env depends on + * this aspect, so importing it back here would be circular. + */ +export const DEFAULT_ENV_FOR_WORKSPACE_ROOT = 'teambit.harmony/empty-env'; + export class EnvsMain { /** * Envs that are failed to load @@ -229,6 +241,20 @@ export class EnvsMain { return new EnvDefinition(DEFAULT_ENV, defaultEnv); } + /** + * the default env to fall back to when the component has no env configured on it. it is only + * different from getDefaultEnv() for the workspace-root component, see + * DEFAULT_ENV_FOR_WORKSPACE_ROOT. note this is a *fallback* - an env explicitly set on the + * component (`bit env set`) is resolved earlier and always wins. + */ + getDefaultEnvForComponent(component: Component): EnvDefinition { + if (component.state._consumer?.componentMap?.rootDir !== WORKSPACE_ROOT_DIR) return this.getDefaultEnv(); + const emptyEnv = this.envSlot.get(DEFAULT_ENV_FOR_WORKSPACE_ROOT); + // empty-env is a core aspect, but be defensive: a missing env must not break loading. + if (!emptyEnv) return this.getDefaultEnv(); + return new EnvDefinition(DEFAULT_ENV_FOR_WORKSPACE_ROOT, emptyEnv); + } + getCoreEnvsIds(): string[] { return [ 'teambit.harmony/aspect', @@ -658,7 +684,7 @@ export class EnvsMain { }); ids = uniq(ids); const envId = await this.findFirstEnv(ids); - const finalId = envId || this.getDefaultEnv().id; + const finalId = envId || this.getDefaultEnvForComponent(component).id; return ComponentID.fromString(finalId); } @@ -725,7 +751,7 @@ export class EnvsMain { this.envIds.add(envDefFromList.id); return envDefFromList; } - return this.getDefaultEnv(); + return this.getDefaultEnvForComponent(component); } /** diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index c0f8ad1d889d..32779e5ccfd3 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -1533,7 +1533,14 @@ export class InstallMain { const components = ids.length ? await this.workspace.getMany(ids, loadOpts) : await this.workspace.list(undefined, loadOpts); - return ComponentMap.as(components, (component) => this.workspace.componentDir(component.id)); + // the workspace-root component's dir is the workspace root itself. it is not an installable + // package - it holds the workspace's own config files, nothing depends on it, and handing its + // dir to the package manager makes it collide with the root project (pnpm resolves it to an + // empty "file:" spec and fails to build the lockfile). + const installableComponents = components.filter( + (component) => this.workspace.componentDir(component.id) !== this.workspace.path + ); + return ComponentMap.as(installableComponents, (component) => this.workspace.componentDir(component.id)); } private async onRootAspectAddedSubscriber(_aspectId: ComponentID, inWs: boolean): Promise { From b2c0a9d44adbd636d801a5456ba3c7f92657833b Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 10 Sep 2026 12:23:46 -0400 Subject: [PATCH 005/102] fix(workspace-root): address review findings on the root component - remove/eject: rootDir "." was passed to RemovePath with recursive deletion, so removing the root component wiped the entire workspace - nested components, .bit, .bitmap and unrelated files. its files are the workspace's own, so untracking it now leaves them in place. - re-adding "bit add ." threw, since files were compared against a "./" prefix they never have. - a second component claiming the workspace root was accepted, then failed .bitmap's duplicate-rootDir validation on the next load. now rejected with a message naming the current owner. - "bit add ." skipped dotfiles and enumerated node_modules; it now uses the same ignore list as the rescan, so both agree on what the root component owns. - .bitTmp and the legacy .bit.map.json are excluded from the root file-set. - the .bitignore/.gitignore lookup resolved against the process cwd rather than the workspace. - the writer rejected a rootDir of "." whenever no .bitmap entry existed yet, which also blocked restoring a stashed root component. it now rejects only when a different component owns the root. - .bitmap normalization no longer clears "scope": unlike "version" it is stable after the first export, and clearing it collapsed components from other scopes onto the workspace default on restore. --- components/legacy/bit-map/bit-map.spec.ts | 6 ++- components/legacy/bit-map/bit-map.ts | 8 +++- components/legacy/bit-map/component-map.ts | 24 ++++++++--- components/legacy/bit-map/index.ts | 1 + e2e/harmony/add-harmony.e2e.ts | 42 ++++++++++++++++++ .../component-writer/component-writer.ts | 18 +++++--- .../remove/delete-component-files.ts | 9 ++++ scopes/component/tracker/add-components.ts | 43 ++++++++++++++++--- scopes/workspace/eject/components-ejector.ts | 6 +++ 9 files changed, 136 insertions(+), 21 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 77913cd4804a..b4f58c4c3d91 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -156,9 +156,11 @@ describe('BitMap', function () { normalized = normalizeBitmapContentForVersioning(rawBitmap); parsed = JSON.parse(normalized.slice(normalized.indexOf('{'))); }); - it('should empty the fields that change on every snap and export', () => { + it('should empty the version, which changes on every snap', () => { expect(parsed.comp1.version).to.equal(''); - expect(parsed.comp1.scope).to.equal(''); + }); + it('should keep the scope, so cross-scope components survive a restore', () => { + expect(parsed.comp1.scope).to.equal('my-scope'); }); it('should keep the durable map intact', () => { expect(parsed.comp1.rootDir).to.equal('comp1'); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index d61c51e4414f..458558028ad3 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1062,8 +1062,14 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string // component entries are objects with a mainFile. skips the schema field (a string) and the // lanes key (an object without a mainFile). if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !('mainFile' in entry)) return; + // only "version" is cleared. it changes on every snap - including this component's own entry - + // so keeping it would make the component modified again the moment it is snapped. + // "scope" is deliberately kept: it changes once (on the first export) and is then stable, so it + // costs one extra snap rather than perpetual drift, and clearing it would lose the identity of + // components belonging to a scope other than the workspace default - on restore they would all + // collapse onto the default scope, and same-named components from different scopes would + // overwrite each other. if ('version' in entry) entry.version = ''; - if ('scope' in entry) entry.scope = ''; }); return `${AUTO_GENERATED_MSG}${BITMAP_PREFIX_MESSAGE}${JSON.stringify(parsed, null, 4)}`; } diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index db21afafedab..458505ed130a 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -6,8 +6,10 @@ import type { ComponentID } from '@teambit/component-id'; import { BIT_HIDDEN_DIR, BIT_MAP, + BIT_WORKSPACE_TMP_DIRNAME, DOT_GIT_DIR, Extensions, + OLD_BIT_MAP, PACKAGE_JSON, IGNORE_ROOT_ONLY_LIST, } from '@teambit/legacy.constants'; @@ -46,10 +48,18 @@ export const WORKSPACE_ROOT_DIR = '.'; * * note that `.bitmap` is deliberately NOT here. it is the map of the workspace and a git-free * workspace has to be able to restore it, so the root component tracks it like any other file. - * `.bit` (the local object store) and `.git` are the outputs of versioning, not sources, so they - * stay excluded. + * `.bit` (the local object store), `.git` and `.bitTmp` are outputs of versioning, not sources, and + * `.bit.map.json` is the legacy location of the map itself, so they all stay excluded. + * + * exported so that `bit add .` builds its initial file-set from the same list the rescan uses - + * otherwise the two disagree about what the root component owns. */ -const WORKSPACE_ROOT_IGNORE_LIST = [`${BIT_HIDDEN_DIR}/**`, `${DOT_GIT_DIR}/**`]; +export const WORKSPACE_ROOT_IGNORE_LIST = [ + `${BIT_HIDDEN_DIR}/**`, + `${DOT_GIT_DIR}/**`, + `${BIT_WORKSPACE_TMP_DIRNAME}/**`, + OLD_BIT_MAP, +]; export type ComponentMapFile = { relativePath: PathLinux; @@ -443,9 +453,13 @@ export async function getFilesByDir( isWorkspaceRoot ? pathNormalizeToLinux(match) : pathNormalizeToLinux(match).replace(`${dir}/`, '') ); const filteredByIgnoredFromRoot = relativePathsLinux.filter((match) => !IGNORE_ROOT_ONLY_LIST.includes(match)); + // resolve against the workspace, not the process cwd. `dir` is workspace-relative, so running bit + // from a sub-directory would otherwise look for the ignore file in the wrong place - and + // getBitIgnoreFile() does not swallow ENOENT, so it throws rather than falling back. + const ignoreFileDir = path.join(consumerPath, dir); const bitOrGitIgnore = filteredByIgnoredFromRoot.includes(BIT_IGNORE) - ? await getBitIgnoreFile(dir) - : await getGitIgnoreFile(dir); + ? await getBitIgnoreFile(ignoreFileDir) + : await getGitIgnoreFile(ignoreFileDir); const filteredByBitIgnore = bitOrGitIgnore ? ignore().add(bitOrGitIgnore).filter(filteredByIgnoredFromRoot) : filteredByIgnoredFromRoot; diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 2208bd7d6d53..ea1891779e73 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -16,4 +16,5 @@ export { getIgnoreListHarmony, NextVersion, WORKSPACE_ROOT_DIR, + WORKSPACE_ROOT_IGNORE_LIST, } from './component-map'; diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 93a6557b6710..820d9be48332 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -101,6 +101,48 @@ describe('add command on Harmony', function () { }); }); }); + describe('removing the workspace-root component', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.fs.outputFile('untracked-by-bit.txt', 'not a component file\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.removeComponent('ws-root --silent'); + }); + it('should not delete the workspace', () => { + // its rootDir is the workspace itself, so deleting it takes .bitmap, .bit, every nested + // component and every unrelated file with it. + expect(path.join(helper.scopes.localPath, '.bitmap')).to.be.a.path(); + expect(path.join(helper.scopes.localPath, 'comp1/index.js')).to.be.a.path(); + expect(path.join(helper.scopes.localPath, 'untracked-by-bit.txt')).to.be.a.path(); + expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.path(); + }); + it('should keep the other component tracked', () => { + expect(helper.bitMap.read()).to.have.property('comp1'); + }); + }); + describe('re-adding and double-adding the workspace root', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + }); + it('should allow re-adding the same component', () => { + helper.fs.outputFile('extra.md', 'extra\n'); + expect(() => helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' })).to.not.throw(); + }); + it('should reject a second component claiming the workspace root', () => { + const cmd = () => helper.command.addComponent('.', { i: 'another-root', m: 'README.md' }); + expect(cmd).to.throw('already tracked by'); + }); + it('should pick up dotfiles at add time, not only on the next rescan', () => { + helper.fs.outputFile('.npmrc', 'registry=https://example.com\n'); + const output = helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + expect(output).to.have.string('.npmrc'); + }); + }); describe('writing the workspace-root component to the filesystem', () => { let firstSnap: string; before(() => { diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index 428ed99559d0..3fd3eee462c7 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -133,13 +133,17 @@ export default class ComponentWriter { } async addComponentToBitMap(rootDir: string): Promise { - // "." is a valid rootDir only for the component that owns this workspace's root. it is never a - // valid *target* to write some other component into. - if (rootDir === WORKSPACE_ROOT_DIR && this.existingComponentMap?.rootDir !== WORKSPACE_ROOT_DIR) { - throw new BitError( - `unable to write "${this.component.id.toString()}" to the workspace root. -the workspace root is owned by the workspace itself, a component can only be written into its own directory` - ); + // "." is a valid rootDir only for the component that owns this workspace's root. reject it only + // when a *different* component already owns it - checking `existingComponentMap` alone would + // also reject restoring a root component whose .bitmap entry is not there yet (e.g. loading a + // stashed new component). + if (rootDir === WORKSPACE_ROOT_DIR) { + const currentOwner = this.bitMap.getComponentIdByRootPath(WORKSPACE_ROOT_DIR); + if (currentOwner && !currentOwner.isEqualWithoutVersion(this.component.id)) { + throw new BitError( + `unable to write "${this.component.id.toString()}" to the workspace root, it is already owned by "${currentOwner.toStringWithoutVersion()}"` + ); + } } const filesForBitMap = this.component.files.map((file) => { return { name: file.basename, relativePath: pathNormalizeToLinux(file.relative), test: file.test }; diff --git a/scopes/component/remove/delete-component-files.ts b/scopes/component/remove/delete-component-files.ts index dae0f2970c0a..66bacc478601 100644 --- a/scopes/component/remove/delete-component-files.ts +++ b/scopes/component/remove/delete-component-files.ts @@ -1,6 +1,7 @@ import type { ComponentIdList } from '@teambit/component-id'; import { logger } from '@teambit/legacy.logger'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { Consumer } from '@teambit/legacy.consumer'; export async function deleteComponentsFiles(consumer: Consumer, bitIds: ComponentIdList) { @@ -22,6 +23,14 @@ export async function deleteComponentsFiles(consumer: Consumer, bitIds: Componen } const rootDir = componentMap.rootDir; if (!rootDir) throw new Error(`rootDir is missing from ${id.toString()}`); + if (rootDir === WORKSPACE_ROOT_DIR) { + // this component's rootDir is the workspace itself. deleting it would wipe the entire + // workspace - every nested component, .git, .bit and the .bitmap that maps them all. + // its files are the workspace's own (workspace.jsonc, README, CI config), not component + // source, so untracking it must leave them in place. + logger.debug(`deleteComponentsFiles, skipping the files of ${id.toString()}, it owns the workspace root`); + return; + } dataToPersist.removePath(new RemovePath(rootDir, true)); }); return dataToPersist; diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 3e021087434d..430676675d29 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -11,7 +11,13 @@ import type { BitIdStr } from '@teambit/legacy-bit-id'; import { BitId } from '@teambit/legacy-bit-id'; import { PACKAGE_JSON, VERSION_DELIMITER, AUTO_GENERATED_STAMP } from '@teambit/legacy.constants'; import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; -import { ComponentMap, getIgnoreListHarmony, MissingMainFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { + ComponentMap, + getIgnoreListHarmony, + MissingMainFile, + WORKSPACE_ROOT_DIR, + WORKSPACE_ROOT_IGNORE_LIST, +} from '@teambit/legacy.bit-map'; import { DuplicateIds, EmptyDirectory, ExcludedMainFile, MainFileIsDir, NoFiles, PathsNotExist } from './exceptions'; import { AddingIndividualFiles } from './exceptions/adding-individual-files'; import MissingMainFileMultipleComponents from './exceptions/missing-main-file-multiple-components'; @@ -348,6 +354,11 @@ export default class AddComponents { ) { const existingRootDir = foundComponentFromBitMap.rootDir; if (!existingRootDir) return; // nothing to do. + // every file in the workspace is inside the workspace root, and its paths are already relative + // to it, so there is nothing to check and nothing to rewrite. without this, re-running + // "bit add ." would compare "README.md" against a "./" prefix, decide the files are outside the + // root, and throw. + if (existingRootDir === WORKSPACE_ROOT_DIR) return; const areFilesInsideExistingRootDir = componentFiles.every((file) => pathNormalizeToLinux(file.relativePath).startsWith(`${existingRootDir}/`) ); @@ -507,10 +518,19 @@ you can add the directory these files are located at and it'll change the root d // to "." so it is a real rootDir rather than a falsy one. const relativeComponentPath = this.consumer.getPathRelativeToConsumer(componentPath) || WORKSPACE_ROOT_DIR; this._throwForOutsideConsumer(relativeComponentPath); - throwForExistingParentDir(this.bitMap, relativeComponentPath); + throwForExistingParentDir(this.bitMap, relativeComponentPath, finalBitId); + const isWorkspaceRoot = relativeComponentPath === WORKSPACE_ROOT_DIR; const allMatches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { cwd: this.consumer.getPath(), nodir: true, + // the workspace root is full of dotfiles that belong to it (.gitignore, .github/**). without + // this, "bit add ." records an incomplete file-set that only the next rescan corrects, since + // getFilesByDir() scans with dot: true. + dot: isWorkspaceRoot, + // node_modules is filtered out by gitIgnore below anyway, but enumerating it first is slow + // enough to matter at the workspace root. the rest are bit's own internals - see + // WORKSPACE_ROOT_IGNORE_LIST, which the rescan uses for the same reason. + ignore: isWorkspaceRoot ? ['**/node_modules/**', ...WORKSPACE_ROOT_IGNORE_LIST] : undefined, }); // files of components nested inside this dir belong to them, not to the component being added. const nestedRootDirs = this.bitMap.getNestedRootDirs(relativeComponentPath); @@ -670,16 +690,27 @@ you can add the directory these files are located at and it'll change the root d } } -function throwForExistingParentDir(bitMap: BitMap, relativeToConsumerPath: PathOsBased) { +function throwForExistingParentDir(bitMap: BitMap, relativeToConsumerPath: PathOsBased, addedId?: ComponentID) { const isParentDir = (parent: string) => { const relative = path.relative(parent, relativeToConsumerPath); return relative && !relative.startsWith('..') && !path.isAbsolute(relative); }; bitMap.components.forEach((componentMap) => { if (!componentMap.rootDir) return; - // the workspace-root component contains every other component by design. it subtracts their - // root-dirs from its own file-set, so tracking a dir inside it is not a conflict. - if (componentMap.rootDir === WORKSPACE_ROOT_DIR) return; + if (componentMap.rootDir === WORKSPACE_ROOT_DIR) { + // only one component can own the workspace root. a second one is accepted here but fails + // .bitmap's duplicate-rootDir validation on the next load, so reject it now with a message + // that says which component already owns it. re-adding the same component is fine. + const isSameComponent = addedId?.isEqual(componentMap.id, { ignoreVersion: true }); + if (relativeToConsumerPath === WORKSPACE_ROOT_DIR && !isSameComponent) { + throw new BitError( + `unable to track the workspace root, it is already tracked by "${componentMap.id.toStringWithoutVersion()}"` + ); + } + // otherwise it is not a conflict: the workspace-root component contains every other component + // by design, and subtracts their root-dirs from its own file-set. + return; + } if (isParentDir(componentMap.rootDir)) { throw new ParentDirTracked( componentMap.rootDir, diff --git a/scopes/workspace/eject/components-ejector.ts b/scopes/workspace/eject/components-ejector.ts index 60afa8549ea0..59a87718989e 100644 --- a/scopes/workspace/eject/components-ejector.ts +++ b/scopes/workspace/eject/components-ejector.ts @@ -16,6 +16,7 @@ import { getScopeRemotes } from '@teambit/scope.remotes'; import { componentIdToPackageName } from '@teambit/pkg.modules.component-package-name'; import type { ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { Logger } from '@teambit/logger'; import type { InstallMain } from '@teambit/install'; import { removeComponentsFromNodeModules } from '@teambit/remove'; @@ -164,6 +165,11 @@ your package.json (if existed) has been restored, however, some bit generated da if (!rootDir) { throw new Error('ComponentEjector.removeComponentsFiles expect a componentMap to have rootDir'); } + if (rootDir === WORKSPACE_ROOT_DIR) { + // see the same guard in deleteComponentsFiles - removing this rootDir deletes the whole + // workspace. the files it owns are the workspace's own, so ejecting it leaves them alone. + return; + } dataToPersist.removePath(new RemovePath(rootDir, true)); }); dataToPersist.addBasePath(this.consumer.getPath()); From 99c1fd8081de4cefc5b783b8d86a474800bbfd28 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 10 Sep 2026 16:37:56 -0400 Subject: [PATCH 006/102] fix(component-writer): allow importing the workspace-root component onto "." "bit import --path ." crashed with an undefined path: "--path ." resolves to an empty relative path, which was stored as an empty rootDir. it is now normalized to ".", and the workspace root - which always holds .bit, .bitmap and workspace.jsonc - is no longer rejected as "not empty" for the component that owns it. this is the flow that restores a git-free workspace from its scope. --- e2e/harmony/add-harmony.e2e.ts | 19 +++++++++++++++++++ .../component-writer.main.runtime.ts | 11 ++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 820d9be48332..10f835815b00 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -174,6 +174,25 @@ describe('add command on Harmony', function () { expect(bitmaps).to.deep.equal([path.normalize('.bitmap')]); }); }); + describe('importing it onto the root of an empty workspace', () => { + // this is how a git-free workspace is restored from its scope: the root component's files + // land on top of the freshly initialized workspace, at the root. + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.command.importComponentWithoutInstall('ws-root', '--path .'); + }); + it('should write its files at the workspace root', () => { + expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# workspace root v2\n'); + }); + it('should record "." as its rootDir', () => { + expect(helper.bitMap.read()['ws-root'].rootDir).to.equal('.'); + }); + it('should leave the live .bitmap alone rather than overwrite it with the exported one', () => { + // the exported .bitmap lists comp1. the restored workspace must not inherit that entry. + expect(helper.bitMap.read()).to.not.have.property('comp1'); + }); + }); }); describe('env of the workspace-root component', () => { before(() => { diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 4d7f1008ae40..d45a431ad799 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -19,6 +19,7 @@ import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { isDir, isDirEmptySync, pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ComponentMap } from '@teambit/legacy.bit-map'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { COMPONENT_CONFIG_FILE_NAME } from '@teambit/legacy.constants'; import { DataToPersist } from '@teambit/component.sources'; import type { ConfigMergerMain, WorkspaceConfigUpdateResult } from '@teambit/config-merger'; @@ -272,8 +273,11 @@ export class ComponentWriterMain { component: ConsumerComponent, opts: ManyComponentsWriterParams ): ComponentWriterProps { + // "--path ." resolves to an empty relative path. normalize it to "." so it is a real rootDir + // rather than a falsy one that later turns into an undefined path segment. const componentRootDir: PathLinuxRelative = opts.writeToPath - ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) + ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) || + WORKSPACE_ROOT_DIR : this.consumer.composeRelativeComponentPath(component.id); // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); @@ -351,6 +355,11 @@ either use --path to specify a different directory or modify "defaultDirectory" if (!isDir(componentDir)) { throw new BitError(`unable to import to ${componentDir} because it's a file`); } + // the workspace root is never empty - it holds .bit, .bitmap and workspace.jsonc - so "not + // empty" says nothing about a conflict there. the target is only reachable for a workspace-root + // component (see ComponentWriter.addComponentToBitMap), whose files are meant to land on top of + // the freshly initialized ones when restoring a workspace from its scope. + if (componentDirRelative === WORKSPACE_ROOT_DIR) return; if (!isDirEmptySync(componentDir) && opts.throwForExistingDir) { throw new BitError( `unable to import to ${componentDir}, the directory is not empty. use --override flag to delete the directory and then import` From d2b6186c5466de4f661e7f39f05216d9556a102f Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 12:03:58 -0400 Subject: [PATCH 007/102] feat(workspace): add trackAllFiles to track package.json and other bit-generated files bit drops package.json, a root-level tsconfig.json and lint configs, and the npm/yarn lockfiles from every component because it generates them. a workspace adopted from an existing monorepo owns those files, and without them a workspace restored from the scope can be neither installed nor built. with "trackAllFiles": true in teambit.workspace/workspace, only the git-ignored files and the hard exclusions (node_modules, .env, ...) are left out. --- components/legacy/bit-map/bit-map.ts | 14 ++++-- .../legacy/bit-map/component-map.spec.ts | 44 +++++++++++++++++++ components/legacy/bit-map/component-map.ts | 44 ++++++++++++++----- components/legacy/constants/constants.ts | 8 +++- .../consumer-component/consumer-component.ts | 3 +- .../legacy-workspace-config-interface.ts | 1 + components/legacy/consumer/consumer.ts | 7 ++- e2e/harmony/add-harmony.e2e.ts | 41 +++++++++++++++++ scopes/component/tracker/add-components.ts | 8 +++- scopes/harmony/config/workspace-config.ts | 1 + scopes/workspace/workspace/types.ts | 8 ++++ workspace-jsonc-schema.json | 5 +++ 12 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 components/legacy/bit-map/component-map.spec.ts diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 458558028ad3..76f19725339a 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -66,6 +66,7 @@ export class BitMap { _cacheIdsAllStrWithoutScopeAndVersion: { [idStr: string]: ComponentID } | undefined; allTrackDirs: { [trackDir: string]: ComponentID } | null | undefined; ignoredFiles?: string[]; + trackAllFiles?: boolean; protected updatedIds: { [oldIdStr: string]: ComponentMap } = {}; // needed for out-of-sync where the id is changed during the process constructor( public projectRoot: string, @@ -166,7 +167,12 @@ export class BitMap { return result; } - static async load(dirPath: PathOsBasedAbsolute, defaultScope: string, ignoredFiles?: string[]): Promise { + static async load( + dirPath: PathOsBasedAbsolute, + defaultScope: string, + ignoredFiles?: string[], + trackAllFiles?: boolean + ): Promise { const { currentLocation, defaultLocation } = BitMap.getBitMapLocation(dirPath); const mapFileContent = BitMap.loadRawSync(dirPath); if (!mapFileContent || !currentLocation) { @@ -174,6 +180,7 @@ export class BitMap { } const bitMap = BitMap.loadFromContentWithoutLoadingFiles(mapFileContent, currentLocation, dirPath, defaultScope); bitMap.ignoredFiles = ignoredFiles; + bitMap.trackAllFiles = trackAllFiles; await bitMap.loadFiles(); return bitMap; @@ -233,7 +240,7 @@ export class BitMap { } async loadFiles() { - const gitIgnore = await getGitIgnoreHarmony(this.projectRoot, this.ignoredFiles); + const gitIgnore = await getGitIgnoreHarmony(this.projectRoot, this.ignoredFiles, this.trackAllFiles); await Promise.all( this.components.map(async (componentMap) => { const rootDir = componentMap.rootDir; @@ -243,7 +250,8 @@ export class BitMap { rootDir, this.projectRoot, gitIgnore, - this.getNestedRootDirs(rootDir) + this.getNestedRootDirs(rootDir), + this.trackAllFiles ); componentMap.recentlyTracked = true; } catch (err: any) { diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts new file mode 100644 index 000000000000..94e1108f5ad7 --- /dev/null +++ b/components/legacy/bit-map/component-map.spec.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { getFilesByDir, getGitIgnoreHarmony, getIgnoreListHarmony } from './component-map'; + +describe('trackAllFiles', function () { + this.timeout(0); + let workspacePath: string; + before(async () => { + workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), 'bit-track-all-files-')); + await fs.outputFile(path.join(workspacePath, '.gitignore'), 'dist/\n'); + const componentDir = path.join(workspacePath, 'comp'); + const files = [ + 'index.ts', + 'package.json', + 'tsconfig.json', + 'package-lock.json', + 'dist/index.js', + 'node_modules/dep/index.js', + ]; + await Promise.all(files.map((file) => fs.outputFile(path.join(componentDir, file), ''))); + }); + after(() => fs.remove(workspacePath)); + + const filesOf = async (trackAllFiles: boolean): Promise => { + const gitIgnore = await getGitIgnoreHarmony(workspacePath, undefined, trackAllFiles); + const files = await getFilesByDir('comp', workspacePath, gitIgnore, [], trackAllFiles); + return files.map((file) => file.relativePath).sort(); + }; + + it('should skip the files bit generates, and the lockfiles, by default', async () => { + expect(await filesOf(false)).to.deep.equal(['index.ts']); + }); + it('should track them with the flag on, while still honoring .gitignore and skipping node_modules', async () => { + expect(await filesOf(true)).to.deep.equal(['index.ts', 'package-lock.json', 'package.json', 'tsconfig.json']); + }); + it('should keep the user ignore patterns and the hard exclusions with the flag on', async () => { + const ignoreList = await getIgnoreListHarmony(workspacePath, ['*.bak'], true); + expect(ignoreList).to.include('*.bak'); + expect(ignoreList).to.include('**/node_modules/**'); + expect(ignoreList).to.not.include('package.json'); + }); +}); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 458505ed130a..8416e58e32c4 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -12,6 +12,7 @@ import { OLD_BIT_MAP, PACKAGE_JSON, IGNORE_ROOT_ONLY_LIST, + LOCKFILES_IGNORE_LIST, } from '@teambit/legacy.constants'; import { ValidationError } from '@teambit/legacy.cli.error'; import { logger } from '@teambit/legacy.logger'; @@ -303,14 +304,15 @@ export class ComponentMap { async trackDirectoryChangesHarmony( consumerPath: PathOsBasedAbsolute, ignoredFiles?: string[], - excludeDirs: PathLinux[] = [] + excludeDirs: PathLinux[] = [], + trackAllFiles = false ): Promise { const trackDir = this.rootDir; if (!trackDir) { return; } - const gitIgnore = await getGitIgnoreHarmony(consumerPath, ignoredFiles); - this.files = await getFilesByDir(trackDir, consumerPath, gitIgnore, excludeDirs); + const gitIgnore = await getGitIgnoreHarmony(consumerPath, ignoredFiles, trackAllFiles); + this.files = await getFilesByDir(trackDir, consumerPath, gitIgnore, excludeDirs, trackAllFiles); } updateNextVersion(nextVersion: NextVersion) { @@ -430,7 +432,8 @@ export async function getFilesByDir( dir: string, consumerPath: string, gitIgnore: any, - excludeDirs: PathLinux[] = [] + excludeDirs: PathLinux[] = [], + trackAllFiles = false ): Promise { const isWorkspaceRoot = dir === WORKSPACE_ROOT_DIR; const matches = await globby(isWorkspaceRoot ? '**' : dir, { @@ -452,7 +455,11 @@ export async function getFilesByDir( const relativePathsLinux = filteredMatches.map((match) => isWorkspaceRoot ? pathNormalizeToLinux(match) : pathNormalizeToLinux(match).replace(`${dir}/`, '') ); - const filteredByIgnoredFromRoot = relativePathsLinux.filter((match) => !IGNORE_ROOT_ONLY_LIST.includes(match)); + // the config files "bit ws-config write" generates are not source - unless the workspace declares that + // every file is (trackAllFiles). in a repo adopted from an existing monorepo, the user wrote them. + const filteredByIgnoredFromRoot = trackAllFiles + ? relativePathsLinux + : relativePathsLinux.filter((match) => !IGNORE_ROOT_ONLY_LIST.includes(match)); // resolve against the workspace, not the process cwd. `dir` is workspace-relative, so running bit // from a sub-directory would otherwise look for the ignore file in the wrong place - and // getBitIgnoreFile() does not swallow ENOENT, so it throws rather than falling back. @@ -471,15 +478,30 @@ export async function getFilesByDir( })); } -export async function getGitIgnoreHarmony(consumerPath: string, additionalPatterns?: string[]): Promise { - const ignoreList = await getIgnoreListHarmony(consumerPath, additionalPatterns); +export async function getGitIgnoreHarmony( + consumerPath: string, + additionalPatterns?: string[], + trackAllFiles = false +): Promise { + const ignoreList = await getIgnoreListHarmony(consumerPath, additionalPatterns, trackAllFiles); return ignore().add(ignoreList); } -export async function getIgnoreListHarmony(consumerPath: string, additionalPatterns?: string[]): Promise { - const ignoreList = await retrieveIgnoreList(consumerPath); - // the ability to track package.json is deprecated since Harmony - ignoreList.push(PACKAGE_JSON); +/** + * the patterns git ignores, plus the files bit owns. with `trackAllFiles` the workspace declares that + * bit owns nothing: package.json and the lockfiles are the user's source, so only the git-ignored + * files and the hard exclusions (node_modules, .env and friends) are left out. + */ +export async function getIgnoreListHarmony( + consumerPath: string, + additionalPatterns?: string[], + trackAllFiles = false +): Promise { + const fromIgnoreFiles = await retrieveIgnoreList(consumerPath); + // tracking package.json is deprecated since Harmony - bit generates it + const ignoreList = trackAllFiles + ? fromIgnoreFiles.filter((pattern) => !LOCKFILES_IGNORE_LIST.includes(pattern)) + : [...fromIgnoreFiles, PACKAGE_JSON]; if (additionalPatterns?.length) { ignoreList.push(...additionalPatterns); } diff --git a/components/legacy/constants/constants.ts b/components/legacy/constants/constants.ts index 40caed7ff040..7670fac35b24 100644 --- a/components/legacy/constants/constants.ts +++ b/components/legacy/constants/constants.ts @@ -257,6 +257,11 @@ export const DEFAULT_BIT_ENV = 'production'; export const MergeConfigFilename = 'merge-conflict'; +/** + * generated by npm/yarn. kept apart from IGNORE_LIST so a workspace with `trackAllFiles` can track them. + */ +export const LOCKFILES_IGNORE_LIST = ['**/package-lock.json', '**/yarn.lock']; + /** * use the .gitignore syntax. (not minimatch). * if you want to ignore only from component's root-dir, use `IGNORE_ROOT_ONLY_LIST` constant. @@ -268,8 +273,7 @@ export const IGNORE_LIST = [ '**/.env.**.local', '**/component.json', '**/node_modules/**', - '**/package-lock.json', - '**/yarn.lock', + ...LOCKFILES_IGNORE_LIST, ]; /** diff --git a/components/legacy/consumer-component/consumer-component.ts b/components/legacy/consumer-component/consumer-component.ts index 9eedb6119ac0..1af2cb03cc39 100644 --- a/components/legacy/consumer-component/consumer-component.ts +++ b/components/legacy/consumer-component/consumer-component.ts @@ -610,7 +610,8 @@ async function getLoadedFiles( await componentMap.trackDirectoryChangesHarmony( consumer.getPath(), consumer.config.ignoredFiles, - consumer.bitMap.getNestedRootDirs(componentMap.getRootDir()) + consumer.bitMap.getNestedRootDirs(componentMap.getRootDir()), + consumer.config.trackAllFiles ); const sourceFiles = componentMap.files.map((file) => { const filePath = path.join(bitDir, file.relativePath); diff --git a/components/legacy/consumer-config/legacy-workspace-config-interface.ts b/components/legacy/consumer-config/legacy-workspace-config-interface.ts index 01625967d148..241de25edc57 100644 --- a/components/legacy/consumer-config/legacy-workspace-config-interface.ts +++ b/components/legacy/consumer-config/legacy-workspace-config-interface.ts @@ -30,5 +30,6 @@ export interface ILegacyWorkspaceConfig { write: (options: { workspaceDir: PathOsBasedAbsolute }) => Promise; toVinyl: (workspaceDir: PathOsBasedAbsolute) => Promise; ignoredFiles?: string[]; + trackAllFiles?: boolean; _legacyPlainObject: () => { [prop: string]: any } | undefined; } diff --git a/components/legacy/consumer/consumer.ts b/components/legacy/consumer/consumer.ts index df327349ee3a..b763b12bc7b9 100644 --- a/components/legacy/consumer/consumer.ts +++ b/components/legacy/consumer/consumer.ts @@ -87,7 +87,12 @@ export default class Consumer { this.packageJson = PackageJsonFile.loadSync(projectPath); } async setBitMap() { - this.bitMap = await BitMap.load(this.getPath(), this.config.defaultScope, this.config.ignoredFiles); + this.bitMap = await BitMap.load( + this.getPath(), + this.config.defaultScope, + this.config.ignoredFiles, + this.config.trackAllFiles + ); } setPackageJson(packageJson: PackageJsonFile) { diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 10f835815b00..ce1921fba9e4 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -230,4 +230,45 @@ describe('add command on Harmony', function () { }); }); }); + describe('trackAllFiles: tracking the files bit treats as generated', () => { + // a workspace adopted from an existing monorepo owns its package.json and tsconfig.json files. bit + // normally drops them as generated, and a workspace restored from the scope can then be neither + // installed nor built. + const filesOf = (id: string): string[] => + helper.command.showComponentParsed(id).files.map((file) => file.relativePath); + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.workspaceJsonc.addKeyValToWorkspace('trackAllFiles', true); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.fs.outputFile('comp1/package.json', '{ "name": "comp1", "version": "0.0.1" }\n'); + helper.fs.outputFile('comp1/tsconfig.json', '{}\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('package.json', '{ "name": "monorepo", "private": true }\n'); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + }); + it('should track the package.json and tsconfig.json of a component', () => { + expect(filesOf('comp1')).to.include.members(['package.json', 'tsconfig.json']); + }); + it('should track the package.json of the workspace root', () => { + expect(filesOf('ws-root')).to.include('package.json'); + }); + describe('restoring the workspace from the scope', () => { + before(() => { + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + helper.command.export(); + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.command.importComponentWithoutInstall('ws-root', '--path .'); + helper.command.importComponentWithoutInstall('comp1', '--path comp1'); + }); + it('should write the manifests back, so the workspace can be installed and built', () => { + expect(path.join(helper.scopes.localPath, 'package.json')) + .to.be.a.file() + .with.content('{ "name": "monorepo", "private": true }\n'); + expect(path.join(helper.scopes.localPath, 'comp1/package.json')).to.be.a.file(); + expect(path.join(helper.scopes.localPath, 'comp1/tsconfig.json')).to.be.a.file(); + }); + }); + }); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 430676675d29..32ee81adea2e 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -590,7 +590,7 @@ you can add the directory these files are located at and it'll change the root d async getIgnoreList(): Promise { const consumerPath = this.consumer.getPath(); - return getIgnoreListHarmony(consumerPath, this.consumer.config.ignoredFiles); + return getIgnoreListHarmony(consumerPath, this.consumer.config.ignoredFiles, this.consumer.config.trackAllFiles); } async linkComponents(ids: ComponentID[]) { @@ -780,7 +780,11 @@ export async function addMultipleFromResolvedTrackData( trackData: ResolvedTrackData[] ): Promise { const bitMap = workspace.consumer.bitMap; - const ignoreList = await getIgnoreListHarmony(workspace.path, workspace.consumer.config.ignoredFiles); + const ignoreList = await getIgnoreListHarmony( + workspace.path, + workspace.consumer.config.ignoredFiles, + workspace.consumer.config.trackAllFiles + ); const gitIgnore = ignore().add(ignoreList); const componentMaps = trackData.map((data) => { const { rootDir, files, componentName, defaultScope, mainFile, config } = data; diff --git a/scopes/harmony/config/workspace-config.ts b/scopes/harmony/config/workspace-config.ts index c4f06c205950..575f17036e5d 100644 --- a/scopes/harmony/config/workspace-config.ts +++ b/scopes/harmony/config/workspace-config.ts @@ -404,6 +404,7 @@ export class WorkspaceConfig implements HostConfig { componentsDefaultDirectory, _manageWorkspaces: this.extension('teambit.dependencies/dependency-resolver', true)?.manageWorkspaces, ignoredFiles: this.extension('teambit.workspace/workspace', true)?.ignoredFiles, + trackAllFiles: this.extension('teambit.workspace/workspace', true)?.trackAllFiles, extensions: this.extensions.toConfigObject(), path: this.path, isLegacy: false, diff --git a/scopes/workspace/workspace/types.ts b/scopes/workspace/workspace/types.ts index 0a40dc4334c5..0f9b64e3d7d0 100644 --- a/scopes/workspace/workspace/types.ts +++ b/scopes/workspace/workspace/types.ts @@ -92,6 +92,14 @@ export interface WorkspaceExtConfig { */ ignoredFiles?: string[]; + /** + * If set to `true`, bit tracks every file in a component directory that git would track, including + * the files it normally treats as bit-generated: `package.json`, a `tsconfig.json` and lint configs + * at the component root, and npm/yarn lockfiles. For workspaces adopted from an existing monorepo, + * where those files are the source of truth and bit does not generate them. + */ + trackAllFiles?: boolean; + /** * Scope-name patterns that the workspace trusts when loading aspects (envs, * generators, etc.) imported from those scopes. The effective trust set is: diff --git a/workspace-jsonc-schema.json b/workspace-jsonc-schema.json index 30c9ab6ba439..46b7a7dc5af7 100644 --- a/workspace-jsonc-schema.json +++ b/workspace-jsonc-schema.json @@ -42,6 +42,11 @@ "description": "When true, every bit command auto-syncs the local .bitmap to the latest scope HEAD on the first run after `git pull` (sentinel-driven, paid at most once per pull). Pair with `bit ci merge --no-bitmap-commit` to support repos with branch protection that forbid CI commits on the default branch.", "type": "boolean", "default": false + }, + "trackAllFiles": { + "description": "When true, bit tracks every file in a component directory that git would track, including the files it normally treats as bit-generated: package.json, a tsconfig.json and lint configs at the component root, and npm/yarn lockfiles. For workspaces adopted from an existing monorepo, where those files are the source of truth.", + "type": "boolean", + "default": false } }, "required": [ From 442238c150a09b1ec883be00fabefe3bc8ceb6ae Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 14:20:48 -0400 Subject: [PATCH 008/102] refactor(workspace-root): address review and simplify the root-component scan - compose the ignore list instead of subtracting, so user lockfile patterns survive trackAllFiles - one bitmap method rescans a component; the directory scan has no root special cases left - normalize .bitmap in one helper shared by the loader and the modified-check (quick status) - reject a second owner of any rootDir at add time; refuse to move the root's main file - importing the root onto "." of an established workspace requires --override - ignore a .git pointer file (worktrees); never delete the live .bitmap on checkout Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 45 ++++++- components/legacy/bit-map/bit-map.ts | 121 ++++++++++++------ .../legacy/bit-map/component-map.spec.ts | 114 ++++++++++++----- components/legacy/bit-map/component-map.ts | 89 ++++++------- components/legacy/bit-map/index.ts | 4 +- components/legacy/constants/constants.ts | 23 ++-- .../consumer-component/consumer-component.ts | 23 +--- e2e/harmony/add-harmony.e2e.ts | 44 ++++++- scopes/component/checkout/checkout-version.ts | 3 + .../component-writer.main.runtime.ts | 42 +++++- .../component-writer/component-writer.ts | 35 +---- scopes/component/tracker/add-components.ts | 83 ++++++------ .../git/modules/ignore-file-reader/ignore.ts | 14 +- .../git/modules/ignore-file-reader/index.ts | 2 +- .../workspace/install/install.main.runtime.ts | 5 +- scopes/workspace/workspace/workspace.ts | 6 +- 16 files changed, 407 insertions(+), 246 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index b4f58c4c3d91..964af170ad0b 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; import { logger } from '@teambit/legacy.logger'; -import { BitMap, normalizeBitmapContentForVersioning } from './bit-map'; +import { BitMap, fileContentsForVersioning, normalizeBitmapContentForVersioning } from './bit-map'; import { WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -172,4 +172,47 @@ describe('BitMap', function () { expect(normalizeBitmapContentForVersioning(normalized)).to.equal(normalized); }); }); + describe('a rootDir with one owner', () => { + const componentParams = { + componentId: ComponentID.fromObject({ name: 'comp1' }, 'my-scope'), + files: [{ name: 'index.ts', relativePath: 'index.ts', test: false }], + mainFile: 'index.ts', + defaultScope: 'my-scope', + rootDir: 'packages/comp1', + }; + it('should reject a second component with the same rootDir, rather than leave it for the next load', async () => { + const bitMap = await getBitmapInstance(); + bitMap.addComponent(componentParams); + const addAnother = () => + bitMap.addComponent({ ...componentParams, componentId: ComponentID.fromObject({ name: 'comp2' }, 'my-scope') }); + expect(addAnother).to.throw('already used by another component'); + }); + it('should let the same component be added again', async () => { + const bitMap = await getBitmapInstance(); + bitMap.addComponent(componentParams); + expect(() => bitMap.addComponent(componentParams)).to.not.throw(); + }); + }); + describe('fileContentsForVersioning', () => { + const rawBitmap = Buffer.from( + JSON.stringify({ + comp1: { name: 'comp1', scope: 'my-scope', version: 'abc', mainFile: 'index.ts', rootDir: 'comp1' }, + }) + ); + const params = (name: string, rootDir: string) => ({ + componentId: ComponentID.fromObject({ name }, 'my-scope'), + files: [{ name: 'README.md', relativePath: 'README.md', test: false }], + mainFile: 'README.md', + defaultScope: 'my-scope', + rootDir, + }); + it('should normalize only the .bitmap of the workspace-root component', async () => { + const bitMap = await getBitmapInstance(); + const rootMap = bitMap.addComponent(params('ws-root', WORKSPACE_ROOT_DIR)); + const nestedMap = bitMap.addComponent(params('comp1', 'packages/comp1')); + expect(fileContentsForVersioning(rootMap, '.bitmap', rawBitmap).toString()).to.have.string('"version": ""'); + expect(fileContentsForVersioning(rootMap, 'README.md', rawBitmap)).to.equal(rawBitmap); + expect(fileContentsForVersioning(nestedMap, '.bitmap', rawBitmap)).to.equal(rawBitmap); + }); + }); }); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 76f19725339a..5e9a1c426225 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -28,7 +28,13 @@ import type { } from '@teambit/toolbox.path.path'; import { pathJoinLinux, pathNormalizeToLinux } from '@teambit/toolbox.path.path'; import type { ComponentMapFile, Config, PathChange } from './component-map'; -import { ComponentMap, getFilesByDir, getGitIgnoreHarmony, WORKSPACE_ROOT_DIR } from './component-map'; +import { + ComponentMap, + getFilesByDir, + getGitIgnoreHarmony, + isWorkspaceMapFile, + WORKSPACE_ROOT_DIR, +} from './component-map'; import { InvalidBitMap, MissingBitMapComponent } from './exceptions'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -106,14 +112,20 @@ export class BitMap { * other components. its file-set subtracts their root-dirs, so the two never claim the same file. */ private throwForExistingParentDir({ id, rootDir }: ComponentMap) { - if (rootDir === WORKSPACE_ROOT_DIR) return; const isParentDir = (parent: string, child: string) => { const relative = path.relative(parent, child); return relative && !relative.startsWith('..'); }; this.components.forEach((existingComponentMap) => { - if (!existingComponentMap.rootDir) return; - if (existingComponentMap.rootDir === WORKSPACE_ROOT_DIR) return; + if (!existingComponentMap.rootDir || existingComponentMap.id.isEqualWithoutVersion(id)) return; + // a root-dir has one owner. .bitmap validates this on load (throwForDuplicateRootDirs), it is + // rejected here so the operation writing the entry fails, not the next command. + if (existingComponentMap.rootDir === rootDir) { + throw new BitError( + `unable to add "${id.toString()}", its rootDir "${rootDir}" is already used by another component "${existingComponentMap.id.toString()}"` + ); + } + if (rootDir === WORKSPACE_ROOT_DIR || existingComponentMap.rootDir === WORKSPACE_ROOT_DIR) return; if (isParentDir(existingComponentMap.rootDir, rootDir)) { throw new BitError( `unable to add "${id.toString()}", its rootDir ${rootDir} is inside ${ @@ -163,7 +175,7 @@ export class BitMap { // Delete and re-add it to make sure it will be at the end delete sorted[SCHEMA_FIELD]; sorted[SCHEMA_FIELD] = parsed[SCHEMA_FIELD]; - const result = `${AUTO_GENERATED_MSG}${BITMAP_PREFIX_MESSAGE}${JSON.stringify(sorted, null, 4)}`; + const result = formatBitMapFile(sorted); return result; } @@ -229,30 +241,43 @@ export class BitMap { * the containing component must subtract them from its own file-set. */ getNestedRootDirs(rootDir: PathLinuxRelative): PathLinuxRelative[] { + // only the workspace root may contain other components (see throwForExistingParentDir), so any + // other root-dir has nothing nested in it. this runs once per component on every load. + if (rootDir !== WORKSPACE_ROOT_DIR) return []; return this.components .map((componentMap) => componentMap.rootDir) - .filter((nested): nested is PathLinuxRelative => { - if (!nested || nested === rootDir) return false; - if (rootDir === WORKSPACE_ROOT_DIR) return nested !== WORKSPACE_ROOT_DIR; - const relative = path.relative(rootDir, nested); - return Boolean(relative) && !relative.startsWith('..'); - }); + .filter((nested): nested is PathLinuxRelative => Boolean(nested) && nested !== WORKSPACE_ROOT_DIR); + } + + /** + * the files of a component are derived from its root-dir, never frozen at add-time. this rescans + * the dir and updates the component-map. it is the one place that knows which files a component + * owns: the workspace ignore rules, the nested components to subtract, and whether the files bit + * normally generates count as source (trackAllFiles). + */ + async loadFilesOf(componentMap: ComponentMap, gitIgnore?: any): Promise { + const rootDir = componentMap.rootDir; + if (!rootDir) return; + componentMap.files = await getFilesByDir( + rootDir, + this.projectRoot, + gitIgnore || (await this.getGitIgnore()), + this.getNestedRootDirs(rootDir), + this.trackAllFiles + ); + } + + private getGitIgnore() { + return getGitIgnoreHarmony(this.projectRoot, this.ignoredFiles, this.trackAllFiles); } async loadFiles() { - const gitIgnore = await getGitIgnoreHarmony(this.projectRoot, this.ignoredFiles, this.trackAllFiles); + const gitIgnore = await this.getGitIgnore(); await Promise.all( this.components.map(async (componentMap) => { - const rootDir = componentMap.rootDir; - if (!rootDir) return; + if (!componentMap.rootDir) return; try { - componentMap.files = await getFilesByDir( - rootDir, - this.projectRoot, - gitIgnore, - this.getNestedRootDirs(rootDir), - this.trackAllFiles - ); + await this.loadFilesOf(componentMap, gitIgnore); componentMap.recentlyTracked = true; } catch (err: any) { componentMap.files = []; @@ -1054,32 +1079,48 @@ type OutputFileParams = { /** * the workspace-root component tracks `.bitmap` so a git-free workspace can be restored from the - * scope. the `version` and `scope` of every entry change on each snap and export - including the - * root component's own entry - so versioning them verbatim would leave that component modified - * immediately after every snap, forever, and never converge. + * scope. the `version` of every entry changes on each snap - including the root component's own + * entry - so versioning it verbatim would leave that component modified immediately after every + * snap, forever, and never converge. only the durable part of the map is versioned: which components + * exist and where they live. the versions are restored from the component heads on import, which is + * the correct source for them anyway. * - * only the durable part of the map is versioned: which components exist and where they live. the - * versions themselves are restored from the component heads on import, which is the correct source - * for them anyway. + * `scope` is deliberately kept: it changes once (on the first export) and is then stable, so it + * costs one extra snap rather than perpetual drift. clearing it would lose the identity of + * components belonging to a scope other than the workspace default - on restore they would all + * collapse onto the default scope, and same-named components from different scopes would overwrite + * each other. */ export function normalizeBitmapContentForVersioning(rawContent: string): string { const parsed = json.parse(rawContent, undefined, true) as Record | undefined; if (!parsed) return rawContent; Object.keys(parsed).forEach((key) => { - const entry = parsed[key]; - // component entries are objects with a mainFile. skips the schema field (a string) and the - // lanes key (an object without a mainFile). - if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !('mainFile' in entry)) return; - // only "version" is cleared. it changes on every snap - including this component's own entry - - // so keeping it would make the component modified again the moment it is snapped. - // "scope" is deliberately kept: it changes once (on the first export) and is then stable, so it - // costs one extra snap rather than perpetual drift, and clearing it would lose the identity of - // components belonging to a scope other than the workspace default - on restore they would all - // collapse onto the default scope, and same-named components from different scopes would - // overwrite each other. - if ('version' in entry) entry.version = ''; + if (key === SCHEMA_FIELD || key === LANE_KEY) return; + if (parsed[key]?.version !== undefined) parsed[key].version = ''; }); - return `${AUTO_GENERATED_MSG}${BITMAP_PREFIX_MESSAGE}${JSON.stringify(parsed, null, 4)}`; + return formatBitMapFile(parsed); +} + +/** + * the contents of a component file as bit versions and compares them. only the workspace-root + * component's `.bitmap` is transformed (see normalizeBitmapContentForVersioning); every other file is + * returned as is. every path that loads a file for versioning or for a modified-check has to go + * through here, otherwise the file on disk and the model disagree and the root never converges. + */ +export function fileContentsForVersioning( + componentMap: ComponentMap, + relativePath: PathLinux, + contents: Buffer +): Buffer { + if (componentMap.rootDir !== WORKSPACE_ROOT_DIR || !isWorkspaceMapFile(relativePath)) return contents; + return Buffer.from(normalizeBitmapContentForVersioning(contents.toString())); +} + +/** + * the `.bitmap` file as written to disk: the auto-generated banner, the prefix message and the map. + */ +function formatBitMapFile(components: Record): string { + return `${AUTO_GENERATED_MSG}${BITMAP_PREFIX_MESSAGE}${JSON.stringify(components, null, 4)}`; } async function outputFile({ diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index 94e1108f5ad7..376a834ab8da 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -2,43 +2,91 @@ import { expect } from 'chai'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { getFilesByDir, getGitIgnoreHarmony, getIgnoreListHarmony } from './component-map'; +import { BIT_HIDDEN_DIR, BIT_WORKSPACE_TMP_DIRNAME, DOT_GIT_DIR } from '@teambit/legacy.constants'; +import { getFilesByDir, getGitIgnoreHarmony, getIgnoreListHarmony, WORKSPACE_ROOT_DIR } from './component-map'; -describe('trackAllFiles', function () { +const createWorkspace = async (prefix: string, files: Record): Promise => { + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + await Promise.all( + Object.entries(files).map(([file, content]) => fs.outputFile(path.join(workspacePath, file), content)) + ); + return workspacePath; +}; + +describe('getFilesByDir', function () { this.timeout(0); - let workspacePath: string; - before(async () => { - workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), 'bit-track-all-files-')); - await fs.outputFile(path.join(workspacePath, '.gitignore'), 'dist/\n'); - const componentDir = path.join(workspacePath, 'comp'); - const files = [ - 'index.ts', - 'package.json', - 'tsconfig.json', - 'package-lock.json', - 'dist/index.js', - 'node_modules/dep/index.js', - ]; - await Promise.all(files.map((file) => fs.outputFile(path.join(componentDir, file), ''))); - }); - after(() => fs.remove(workspacePath)); + describe('trackAllFiles', () => { + let workspacePath: string; + before(async () => { + workspacePath = await createWorkspace('bit-track-all-files-', { + '.gitignore': 'dist/\n', + 'comp/index.ts': '', + 'comp/package.json': '', + 'comp/tsconfig.json': '', + 'comp/package-lock.json': '', + 'comp/dist/index.js': '', + 'comp/node_modules/dep/index.js': '', + }); + }); + after(() => fs.remove(workspacePath)); - const filesOf = async (trackAllFiles: boolean): Promise => { - const gitIgnore = await getGitIgnoreHarmony(workspacePath, undefined, trackAllFiles); - const files = await getFilesByDir('comp', workspacePath, gitIgnore, [], trackAllFiles); - return files.map((file) => file.relativePath).sort(); - }; + const filesOf = async (trackAllFiles: boolean): Promise => { + const gitIgnore = await getGitIgnoreHarmony(workspacePath, undefined, trackAllFiles); + const files = await getFilesByDir('comp', workspacePath, gitIgnore, [], trackAllFiles); + return files.map((file) => file.relativePath).sort(); + }; - it('should skip the files bit generates, and the lockfiles, by default', async () => { - expect(await filesOf(false)).to.deep.equal(['index.ts']); - }); - it('should track them with the flag on, while still honoring .gitignore and skipping node_modules', async () => { - expect(await filesOf(true)).to.deep.equal(['index.ts', 'package-lock.json', 'package.json', 'tsconfig.json']); + it('should skip the files bit generates, and the lockfiles, by default', async () => { + expect(await filesOf(false)).to.deep.equal(['index.ts']); + }); + it('should track them with the flag on, while still honoring .gitignore and skipping node_modules', async () => { + expect(await filesOf(true)).to.deep.equal(['index.ts', 'package-lock.json', 'package.json', 'tsconfig.json']); + }); + it('should keep the user ignore patterns and the hard exclusions with the flag on', async () => { + const ignoreList = await getIgnoreListHarmony(workspacePath, ['*.bak'], true); + expect(ignoreList).to.include('*.bak'); + expect(ignoreList).to.include('**/node_modules/**'); + expect(ignoreList).to.not.include('package.json'); + }); + it('should keep a lockfile pattern the user wrote, even though bit drops its own', async () => { + const otherWorkspace = await createWorkspace('bit-user-lockfile-', { '.gitignore': '**/yarn.lock\n' }); + try { + const ignoreList = await getIgnoreListHarmony(otherWorkspace, undefined, true); + expect(ignoreList).to.include('**/yarn.lock'); + expect(ignoreList).to.not.include('**/package-lock.json'); + } finally { + await fs.remove(otherWorkspace); + } + }); }); - it('should keep the user ignore patterns and the hard exclusions with the flag on', async () => { - const ignoreList = await getIgnoreListHarmony(workspacePath, ['*.bak'], true); - expect(ignoreList).to.include('*.bak'); - expect(ignoreList).to.include('**/node_modules/**'); - expect(ignoreList).to.not.include('package.json'); + + describe('scanning the workspace root', () => { + let workspacePath: string; + before(async () => { + workspacePath = await createWorkspace('bit-workspace-root-scan-', { + 'README.md': '', + '.bitmap': '', + 'workspace.jsonc': '', + '.github/ci.yml': '', + // a git worktree or submodule: .git is a pointer file, not a directory + [DOT_GIT_DIR]: 'gitdir: /elsewhere/.git/worktrees/this\n', + [`${BIT_HIDDEN_DIR}/objects/aa`]: '', + [`${BIT_WORKSPACE_TMP_DIRNAME}/x`]: '', + 'node_modules/dep/index.js': '', + 'packages/comp1/index.ts': '', + }); + }); + after(() => fs.remove(workspacePath)); + + it('should own every file no other component claims, and skip the bit and git internals', async () => { + const gitIgnore = await getGitIgnoreHarmony(workspacePath); + const files = await getFilesByDir(WORKSPACE_ROOT_DIR, workspacePath, gitIgnore, ['packages/comp1']); + expect(files.map((file) => file.relativePath).sort()).to.deep.equal([ + '.bitmap', + '.github/ci.yml', + 'README.md', + 'workspace.jsonc', + ]); + }); }); }); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 8416e58e32c4..b9a3b6939615 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -10,25 +10,20 @@ import { DOT_GIT_DIR, Extensions, OLD_BIT_MAP, - PACKAGE_JSON, IGNORE_ROOT_ONLY_LIST, - LOCKFILES_IGNORE_LIST, + ALWAYS_IGNORE_LIST, + IGNORE_LIST, } from '@teambit/legacy.constants'; import { ValidationError } from '@teambit/legacy.cli.error'; import { logger } from '@teambit/legacy.logger'; import { isValidPath } from '@teambit/legacy.utils'; import { - retrieveIgnoreList, + retrieveUserIgnoreList, BIT_IGNORE, getBitIgnoreFile, getGitIgnoreFile, } from '@teambit/git.modules.ignore-file-reader'; -import type { - PathLinux, - PathLinuxRelative, - PathOsBasedAbsolute, - PathOsBasedRelative, -} from '@teambit/toolbox.path.path'; +import type { PathLinux, PathLinuxRelative, PathOsBasedRelative } from '@teambit/toolbox.path.path'; import { pathJoinLinux, pathNormalizeToLinux, pathRelativeLinux } from '@teambit/toolbox.path.path'; import { removeInternalConfigFields } from '@teambit/legacy.extension-data'; import OutsideRootDir from './exceptions/outside-root-dir'; @@ -44,19 +39,36 @@ export type Config = { [aspectId: string]: Record | '-' }; export const WORKSPACE_ROOT_DIR = '.'; /** - * paths the workspace-root component must never own. relevant only for that component - for any - * other component these live outside its root-dir and are never reached by the scan. + * `.bitmap` is the live map of the workspace. the workspace-root component versions it, so a git-free + * workspace can be restored from the scope, but no operation may write or delete the one on disk + * from a versioned copy: writing it into a sub-directory (importing a workspace-root component into + * another workspace) creates a broken nested workspace there, and writing or deleting it at the root + * clobbers the map the running command is mutating. the rest of the component's files are handled + * normally. + */ +export function isWorkspaceMapFile(relativePath: PathLinux): boolean { + return relativePath === BIT_MAP; +} + +/** + * excluded from every directory scan, before the ignore files are consulted. + * node_modules is filtered by the ignore list later on anyway, but enumerating it first hurts + * performance dramatically. the rest are bit's own outputs at the workspace root: `.bit` (the local + * object store), `.git` and `.bitTmp` are outputs of versioning, not sources, `.bit.map.json` is the + * legacy location of the map itself, and `.git` is also a file in git worktrees and submodules (a + * pointer to the real git dir). they only exist at the root, so they only matter for the + * workspace-root component; for any other component they live outside its root-dir. * * note that `.bitmap` is deliberately NOT here. it is the map of the workspace and a git-free * workspace has to be able to restore it, so the root component tracks it like any other file. - * `.bit` (the local object store), `.git` and `.bitTmp` are outputs of versioning, not sources, and - * `.bit.map.json` is the legacy location of the map itself, so they all stay excluded. * - * exported so that `bit add .` builds its initial file-set from the same list the rescan uses - - * otherwise the two disagree about what the root component owns. + * exported so that `bit add` builds its initial file-set from the same list the rescan uses - + * otherwise the two disagree about what the workspace-root component owns. */ -export const WORKSPACE_ROOT_IGNORE_LIST = [ +export const SCAN_IGNORE_LIST = [ + '**/node_modules/**', `${BIT_HIDDEN_DIR}/**`, + DOT_GIT_DIR, `${DOT_GIT_DIR}/**`, `${BIT_WORKSPACE_TMP_DIRNAME}/**`, OLD_BIT_MAP, @@ -297,24 +309,6 @@ export class ComponentMap { return Boolean(this.rootDir); } - /** - * if the component dir has changed since the last tracking, re-scan the component-dir to get the - * updated list of the files - */ - async trackDirectoryChangesHarmony( - consumerPath: PathOsBasedAbsolute, - ignoredFiles?: string[], - excludeDirs: PathLinux[] = [], - trackAllFiles = false - ): Promise { - const trackDir = this.rootDir; - if (!trackDir) { - return; - } - const gitIgnore = await getGitIgnoreHarmony(consumerPath, ignoredFiles, trackAllFiles); - this.files = await getFilesByDir(trackDir, consumerPath, gitIgnore, excludeDirs, trackAllFiles); - } - updateNextVersion(nextVersion: NextVersion) { this.nextVersion = nextVersion; this.validate(); @@ -435,26 +429,16 @@ export async function getFilesByDir( excludeDirs: PathLinux[] = [], trackAllFiles = false ): Promise { - const isWorkspaceRoot = dir === WORKSPACE_ROOT_DIR; - const matches = await globby(isWorkspaceRoot ? '**' : dir, { + const matches = await globby(pathJoinLinux(dir, '**'), { cwd: consumerPath, dot: true, onlyFiles: true, - // must ignore node_modules at this stage, although we check for gitignore later on. - // otherwise, it hurts performance dramatically for components that have node_modules in the comp-dir. - ignore: [ - isWorkspaceRoot ? '**/node_modules/**' : `${dir}/node_modules/`, - ...(isWorkspaceRoot ? WORKSPACE_ROOT_IGNORE_LIST : []), - ...excludeDirs.map((excludeDir) => `${excludeDir}/**`), - ], + ignore: [...SCAN_IGNORE_LIST, ...excludeDirs.map((excludeDir) => `${excludeDir}/**`)], }); if (!matches.length) throw new ComponentNotFoundInPath(dir); const filteredMatches: string[] = gitIgnore.filter(matches); - // the path is relative to consumer. remove the rootDir. for the workspace-root component the - // paths are already relative to the consumer, so there is nothing to strip. - const relativePathsLinux = filteredMatches.map((match) => - isWorkspaceRoot ? pathNormalizeToLinux(match) : pathNormalizeToLinux(match).replace(`${dir}/`, '') - ); + // the paths are relative to the workspace. make them relative to the component's root-dir. + const relativePathsLinux = filteredMatches.map((match) => pathRelativeLinux(dir, match)); // the config files "bit ws-config write" generates are not source - unless the workspace declares that // every file is (trackAllFiles). in a repo adopted from an existing monorepo, the user wrote them. const filteredByIgnoredFromRoot = trackAllFiles @@ -497,11 +481,10 @@ export async function getIgnoreListHarmony( additionalPatterns?: string[], trackAllFiles = false ): Promise { - const fromIgnoreFiles = await retrieveIgnoreList(consumerPath); - // tracking package.json is deprecated since Harmony - bit generates it - const ignoreList = trackAllFiles - ? fromIgnoreFiles.filter((pattern) => !LOCKFILES_IGNORE_LIST.includes(pattern)) - : [...fromIgnoreFiles, PACKAGE_JSON]; + const userIgnoreList = await retrieveUserIgnoreList(consumerPath); + // with trackAllFiles the workspace declares that bit generates nothing: package.json and the + // lockfiles are the user's source. what the user ignores stays ignored either way. + const ignoreList = [...userIgnoreList, ...(trackAllFiles ? ALWAYS_IGNORE_LIST : IGNORE_LIST)]; if (additionalPatterns?.length) { ignoreList.push(...additionalPatterns); } diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index ea1891779e73..6da861c52918 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -6,6 +6,7 @@ export { SCHEMA_FIELD, LANE_KEY, normalizeBitmapContentForVersioning, + fileContentsForVersioning, } from './bit-map'; export { MissingBitMapComponent, MissingMainFile, InvalidBitMap } from './exceptions'; export { @@ -14,7 +15,8 @@ export { ComponentMap, Config, getIgnoreListHarmony, + isWorkspaceMapFile, NextVersion, + SCAN_IGNORE_LIST, WORKSPACE_ROOT_DIR, - WORKSPACE_ROOT_IGNORE_LIST, } from './component-map'; diff --git a/components/legacy/constants/constants.ts b/components/legacy/constants/constants.ts index 7670fac35b24..2536c29be339 100644 --- a/components/legacy/constants/constants.ts +++ b/components/legacy/constants/constants.ts @@ -258,24 +258,31 @@ export const DEFAULT_BIT_ENV = 'production'; export const MergeConfigFilename = 'merge-conflict'; /** - * generated by npm/yarn. kept apart from IGNORE_LIST so a workspace with `trackAllFiles` can track them. - */ -export const LOCKFILES_IGNORE_LIST = ['**/package-lock.json', '**/yarn.lock']; - -/** + * never tracked, no matter what: secrets, os artifacts and installed packages. * use the .gitignore syntax. (not minimatch). - * if you want to ignore only from component's root-dir, use `IGNORE_ROOT_ONLY_LIST` constant. */ -export const IGNORE_LIST = [ +export const ALWAYS_IGNORE_LIST = [ '**/.DS_Store', '**/.env', '**/.env.local', '**/.env.**.local', '**/component.json', '**/node_modules/**', - ...LOCKFILES_IGNORE_LIST, ]; +/** + * the files bit generates, so tracking them is deprecated since Harmony. a workspace with + * `trackAllFiles` (adopted from an existing monorepo, where the user wrote them) tracks them as source. + */ +export const BIT_GENERATED_IGNORE_LIST = ['package.json', '**/package-lock.json', '**/yarn.lock']; + +/** + * everything bit ignores by default, on top of the user's .gitignore/.bitignore. + * use the .gitignore syntax. (not minimatch). + * if you want to ignore only from component's root-dir, use `IGNORE_ROOT_ONLY_LIST` constant. + */ +export const IGNORE_LIST = [...ALWAYS_IGNORE_LIST, ...BIT_GENERATED_IGNORE_LIST]; + /** * these files are ignored only if they exist in the component's rootDir. * avoid adding any wildcards or magic characters. specify the filename only. diff --git a/components/legacy/consumer-component/consumer-component.ts b/components/legacy/consumer-component/consumer-component.ts index 1af2cb03cc39..514032557d0e 100644 --- a/components/legacy/consumer-component/consumer-component.ts +++ b/components/legacy/consumer-component/consumer-component.ts @@ -7,13 +7,7 @@ import { IssuesList } from '@teambit/component-issues'; import { BitId } from '@teambit/legacy-bit-id'; import { BitError } from '@teambit/bit-error'; import type { BuildStatus } from '@teambit/legacy.constants'; -import { - getCloudDomain, - BIT_MAP, - BIT_WORKSPACE_TMP_DIRNAME, - DEFAULT_LANGUAGE, - Extensions, -} from '@teambit/legacy.constants'; +import { getCloudDomain, BIT_WORKSPACE_TMP_DIRNAME, DEFAULT_LANGUAGE, Extensions } from '@teambit/legacy.constants'; import type { Doclet } from '@teambit/semantics.doc-parser'; import { parser as docsParser } from '@teambit/semantics.doc-parser'; import { logger } from '@teambit/legacy.logger'; @@ -22,7 +16,7 @@ import type { PathLinux, PathOsBased, PathOsBasedRelative } from '@teambit/toolb import { pathNormalizeToLinux } from '@teambit/toolbox.path.path'; import { sha1 } from '@teambit/toolbox.crypto.sha1'; import type { ComponentMap } from '@teambit/legacy.bit-map'; -import { normalizeBitmapContentForVersioning, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { fileContentsForVersioning } from '@teambit/legacy.bit-map'; import { IgnoredDirectory } from './exceptions/ignored-directory'; import type { Dist, PackageJsonFile, DataToPersist } from '@teambit/component.sources'; import { License, SourceFile } from '@teambit/component.sources'; @@ -607,20 +601,11 @@ async function getLoadedFiles( logger.error(`rethrowing an error of ${componentMap.noFilesError.message}`); throw componentMap.noFilesError; } - await componentMap.trackDirectoryChangesHarmony( - consumer.getPath(), - consumer.config.ignoredFiles, - consumer.bitMap.getNestedRootDirs(componentMap.getRootDir()), - consumer.config.trackAllFiles - ); + await consumer.bitMap.loadFilesOf(componentMap); const sourceFiles = componentMap.files.map((file) => { const filePath = path.join(bitDir, file.relativePath); const sourceFile = SourceFile.load(filePath, bitDir, consumer.getPath(), { test: file.test || false }); - // the workspace-root component owns .bitmap. strip the fields that change on every snap so the - // component converges instead of being modified again the moment it is snapped. - if (componentMap.rootDir === WORKSPACE_ROOT_DIR && file.relativePath === BIT_MAP) { - sourceFile.contents = Buffer.from(normalizeBitmapContentForVersioning(sourceFile.contents.toString())); - } + sourceFile.contents = fileContentsForVersioning(componentMap, file.relativePath, sourceFile.contents); return sourceFile; }); const filePaths = componentMap.getAllFilesPaths(); diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index ce1921fba9e4..cfa8cf40b997 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -47,7 +47,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); // written after tracking. the root file-set is re-scanned, not frozen at add-time. helper.fs.outputFile('LICENSE', 'MIT\n'); - rootFiles = helper.command.showComponentParsed('ws-root').files.map((file) => file.relativePath); + rootFiles = helper.command.getComponentFiles('ws-root'); }); it('should save "." as the rootDir', () => { expect(helper.bitMap.read()['ws-root'].rootDir).to.equal('.'); @@ -83,13 +83,17 @@ describe('add command on Harmony', function () { // own entry. without normalizing those fields out, the component would never converge. expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(0); }); + it('should not be modified on the quick-status path either, which hashes the files on disk', () => { + const quickStatus = JSON.parse(helper.command.runCmd('bit status --quick --json')); + expect(quickStatus.modified).to.have.lengthOf(0); + }); describe('adding a new component inside the workspace root', () => { before(() => { helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); helper.command.addComponent('comp2', { i: 'comp2' }); }); it('should let the new component take the files from the root component', () => { - const rootFiles = helper.command.showComponentParsed('ws-root').files.map((file) => file.relativePath); + const rootFiles = helper.command.getComponentFiles('ws-root'); expect(rootFiles.some((file) => file.startsWith('comp2/'))).to.be.false; }); it('should mark the root component as modified, because the map changed', () => { @@ -143,6 +147,17 @@ describe('add command on Harmony', function () { expect(output).to.have.string('.npmrc'); }); }); + describe('adding a nested component that holds the main file of the workspace root', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('packages/comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.command.addComponent('.', { i: 'ws-root', m: 'packages/comp1/index.js' }); + }); + it('should refuse, because the root would fail to load without its main file', () => { + const cmd = () => helper.command.addComponent('packages/comp1', { i: 'comp1' }); + expect(cmd).to.throw('main file of the workspace-root component'); + }); + }); describe('writing the workspace-root component to the filesystem', () => { let firstSnap: string; before(() => { @@ -193,6 +208,25 @@ describe('add command on Harmony', function () { expect(helper.bitMap.read()).to.not.have.property('comp1'); }); }); + describe('importing it onto the root of a workspace that already tracks components', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); + helper.command.addComponent('comp2', { i: 'comp2' }); + helper.fs.outputFile('README.md', '# my own readme\n'); + }); + it('should refuse to overwrite the root files without --override', () => { + // this workspace is not being restored - its root files are the user's own. + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); + expect(cmd).to.throw('use --override'); + expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# my own readme\n'); + }); + it('should overwrite them with --override', () => { + helper.command.importComponentWithoutInstall('ws-root', '--path . --override'); + expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# workspace root v2\n'); + }); + }); }); describe('env of the workspace-root component', () => { before(() => { @@ -234,8 +268,6 @@ describe('add command on Harmony', function () { // a workspace adopted from an existing monorepo owns its package.json and tsconfig.json files. bit // normally drops them as generated, and a workspace restored from the scope can then be neither // installed nor built. - const filesOf = (id: string): string[] => - helper.command.showComponentParsed(id).files.map((file) => file.relativePath); before(() => { helper.scopeHelper.setWorkspaceWithRemoteScope(); helper.workspaceJsonc.addKeyValToWorkspace('trackAllFiles', true); @@ -248,10 +280,10 @@ describe('add command on Harmony', function () { helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); }); it('should track the package.json and tsconfig.json of a component', () => { - expect(filesOf('comp1')).to.include.members(['package.json', 'tsconfig.json']); + expect(helper.command.getComponentFiles('comp1')).to.include.members(['package.json', 'tsconfig.json']); }); it('should track the package.json of the workspace root', () => { - expect(filesOf('ws-root')).to.include('package.json'); + expect(helper.command.getComponentFiles('ws-root')).to.include('package.json'); }); describe('restoring the workspace from the scope', () => { before(() => { diff --git a/scopes/component/checkout/checkout-version.ts b/scopes/component/checkout/checkout-version.ts index 628bea40b41c..627b42a4c7cb 100644 --- a/scopes/component/checkout/checkout-version.ts +++ b/scopes/component/checkout/checkout-version.ts @@ -4,6 +4,7 @@ import type { Version } from '@teambit/objects'; import type { SourceFile } from '@teambit/component.sources'; import { RemovePath, DataToPersist } from '@teambit/component.sources'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; +import { isWorkspaceMapFile } from '@teambit/legacy.bit-map'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import { BitError } from '@teambit/bit-error'; import chalk from 'chalk'; @@ -127,6 +128,8 @@ export async function removeFilesIfNeeded( const dataToPersist = new DataToPersist(); filePathsFromFS.forEach((file) => { const filename = pathNormalizeToLinux(file.relative); + // the live .bitmap is never deleted from a versioned copy, see isWorkspaceMapFile + if (isWorkspaceMapFile(filename)) return; if (!filesStatus[filename]) { // @ts-ignore todo: typescript has a good point here. it should be the string "removed", not chalk.green(removed). filesStatus[filename] = FileStatus.removed; diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index d45a431ad799..04da7993c62f 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -19,7 +19,7 @@ import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { isDir, isDirEmptySync, pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ComponentMap } from '@teambit/legacy.bit-map'; -import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { COMPONENT_CONFIG_FILE_NAME } from '@teambit/legacy.constants'; import { DataToPersist } from '@teambit/component.sources'; import type { ConfigMergerMain, WorkspaceConfigUpdateResult } from '@teambit/config-merger'; @@ -284,7 +284,7 @@ export class ComponentWriterMain { // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. if (this.consumer && !opts.writeToEmptyDir) { - this.throwErrorWhenDirectoryNotEmpty(componentRootDir, existingComponentMap, opts); + this.throwErrorWhenDirectoryNotEmpty(component, componentRootDir, existingComponentMap, opts); } return { workspace: this.workspace, @@ -334,7 +334,36 @@ to move all component files to a different directory, run bit remove and then bi return componentMap.rootDir === componentDirRelative; } + /** + * the workspace root is never empty - it holds .bit, .bitmap and workspace.jsonc - so "not empty" + * says nothing about a conflict there. the target is only reachable for a workspace-root component. + * restoring a git-free workspace from its scope starts from `bit init`, and the root files are meant + * to land on top of the freshly initialized ones. a workspace that already tracks components is not + * being restored: its workspace.jsonc and root files would be overwritten silently, so it takes + * --override like any other occupied directory. + */ + private throwForOccupiedWorkspaceRoot(component: ConsumerComponent, opts: ManyComponentsWriterParams) { + if (!opts.throwForExistingDir) return; + const isFreshWorkspace = this.consumer.bitMap.components.every((componentMap) => + componentMap.id.isEqualWithoutVersion(component.id) + ); + if (isFreshWorkspace) return; + const filesToOverwrite = component.files + .map((file) => pathNormalizeToLinux(file.relative)) + .filter( + (relativePath) => !isWorkspaceMapFile(relativePath) && fs.existsSync(this.consumer.toAbsolutePath(relativePath)) + ); + if (!filesToOverwrite.length) return; + const shown = filesToOverwrite.slice(0, 10).join(', '); + const rest = filesToOverwrite.length > 10 ? ` and ${filesToOverwrite.length - 10} more` : ''; + throw new BitError( + `unable to import "${component.id.toString()}" to the workspace root, this workspace already tracks other components and the import would overwrite ${shown}${rest}. +use --override to overwrite them` + ); + } + private throwErrorWhenDirectoryNotEmpty( + component: ConsumerComponent, componentDirRelative: PathLinuxRelative, componentMap: ComponentMap | null | undefined, opts: ManyComponentsWriterParams @@ -355,11 +384,10 @@ either use --path to specify a different directory or modify "defaultDirectory" if (!isDir(componentDir)) { throw new BitError(`unable to import to ${componentDir} because it's a file`); } - // the workspace root is never empty - it holds .bit, .bitmap and workspace.jsonc - so "not - // empty" says nothing about a conflict there. the target is only reachable for a workspace-root - // component (see ComponentWriter.addComponentToBitMap), whose files are meant to land on top of - // the freshly initialized ones when restoring a workspace from its scope. - if (componentDirRelative === WORKSPACE_ROOT_DIR) return; + if (componentDirRelative === WORKSPACE_ROOT_DIR) { + this.throwForOccupiedWorkspaceRoot(component, opts); + return; + } if (!isDirEmptySync(componentDir) && opts.throwForExistingDir) { throw new BitError( `unable to import to ${componentDir}, the directory is not empty. use --override flag to delete the directory and then import` diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index 3fd3eee462c7..1c989fd4531c 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -3,8 +3,7 @@ import type { Scope } from '@teambit/legacy.scope'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { BitMap, ComponentMap } from '@teambit/legacy.bit-map'; -import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; -import { BIT_MAP } from '@teambit/legacy.constants'; +import { isWorkspaceMapFile } from '@teambit/legacy.bit-map'; import type { ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -106,9 +105,12 @@ export default class ComponentWriter { if (this.deleteBitDirContent) { this.component.dataToPersist.removePath(new RemovePath(this.writeToPath)); } - const filesToWrite = this.component.files.filter((file) => !this.shouldSkipWritingFile(file)); - filesToWrite.forEach((file) => (file.override = this.override)); - filesToWrite.map((file) => this.component.dataToPersist.addFile(file)); + this.component.files.forEach((file) => { + // the live map is never written from a versioned copy, see isWorkspaceMapFile + if (isWorkspaceMapFile(pathNormalizeToLinux(file.relative))) return; + file.override = this.override; + this.component.dataToPersist.addFile(file); + }); if (this.component.license && this.component.license.contents) { this.component.license.updatePaths({ newBase: this.writeToPath }); @@ -121,30 +123,7 @@ export default class ComponentWriter { } } - /** - * `.bitmap` is only ever a file of the workspace-root component, and it is never safe to write: - * writing it into a sub-directory (importing a workspace-root component into another workspace) - * creates a broken nested workspace there, and writing it onto the workspace root would clobber - * the live map with a stale one - while the very operation doing the write is mutating it. - * the rest of the component's files are written normally. - */ - private shouldSkipWritingFile(file: { relative: string }): boolean { - return pathNormalizeToLinux(file.relative) === BIT_MAP; - } - async addComponentToBitMap(rootDir: string): Promise { - // "." is a valid rootDir only for the component that owns this workspace's root. reject it only - // when a *different* component already owns it - checking `existingComponentMap` alone would - // also reject restoring a root component whose .bitmap entry is not there yet (e.g. loading a - // stashed new component). - if (rootDir === WORKSPACE_ROOT_DIR) { - const currentOwner = this.bitMap.getComponentIdByRootPath(WORKSPACE_ROOT_DIR); - if (currentOwner && !currentOwner.isEqualWithoutVersion(this.component.id)) { - throw new BitError( - `unable to write "${this.component.id.toString()}" to the workspace root, it is already owned by "${currentOwner.toStringWithoutVersion()}"` - ); - } - } const filesForBitMap = this.component.files.map((file) => { return { name: file.basename, relativePath: pathNormalizeToLinux(file.relative), test: file.test }; }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 32ee81adea2e..d134f0f265f5 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -15,8 +15,8 @@ import { ComponentMap, getIgnoreListHarmony, MissingMainFile, + SCAN_IGNORE_LIST, WORKSPACE_ROOT_DIR, - WORKSPACE_ROOT_IGNORE_LIST, } from '@teambit/legacy.bit-map'; import { DuplicateIds, EmptyDirectory, ExcludedMainFile, MainFileIsDir, NoFiles, PathsNotExist } from './exceptions'; import { AddingIndividualFiles } from './exceptions/adding-individual-files'; @@ -238,6 +238,11 @@ export default class AddComponents { const foundComponentFromBitMap = this.bitMap.getComponentIfExist(component.componentId, { ignoreVersion: true, }); + // the workspace-root component owns every file no other component claims, so it "owns" a file + // only until a more specific component is added for it: the component being added wins, and the + // root subtracts the new root-dir from its own file-set on the next scan. the one file it cannot + // give away is its main file - without it, it fails to load from the next scan on. + const workspaceRootMap = this.bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); const componentFilesP = files.map(async (file: ComponentMapFile) => { // $FlowFixMe null is removed later on const filePath = path.join(consumerPath, file.relativePath); @@ -247,15 +252,21 @@ export default class AddComponents { } const caseSensitive = false; const existingIdOfFile = this.bitMap.getComponentIdByPath(file.relativePath, caseSensitive); - // the workspace-root component owns every file no other component claims, so it "owns" this - // file only until a more specific component is added for it. the component being added wins, - // and the root subtracts the new root-dir from its own file-set on the next scan. - const existingIsWorkspaceRoot = existingIdOfFile - ? this.bitMap.getComponentIfExist(existingIdOfFile, { ignoreVersion: true })?.rootDir === WORKSPACE_ROOT_DIR - : false; - const idOfFileIsDifferent = - existingIdOfFile && !existingIdOfFile.isEqual(parsedBitId) && !existingIsWorkspaceRoot; - if (idOfFileIsDifferent) { + const idOfFileIsDifferent = existingIdOfFile && !existingIdOfFile.isEqual(parsedBitId); + const ownedByWorkspaceRoot = Boolean( + workspaceRootMap && existingIdOfFile?.isEqualWithoutVersion(workspaceRootMap.id) + ); + if ( + workspaceRootMap && + idOfFileIsDifferent && + ownedByWorkspaceRoot && + file.relativePath === workspaceRootMap.mainFile + ) { + throw new BitError( + `unable to add "${file.relativePath}" to "${parsedBitId.toString()}", it is the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` + ); + } + if (idOfFileIsDifferent && !ownedByWorkspaceRoot) { // not imported component file but exists in bitmap // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! if (this.warnings.alreadyUsed[existingIdOfFile]) { @@ -519,27 +530,18 @@ you can add the directory these files are located at and it'll change the root d const relativeComponentPath = this.consumer.getPathRelativeToConsumer(componentPath) || WORKSPACE_ROOT_DIR; this._throwForOutsideConsumer(relativeComponentPath); throwForExistingParentDir(this.bitMap, relativeComponentPath, finalBitId); - const isWorkspaceRoot = relativeComponentPath === WORKSPACE_ROOT_DIR; - const allMatches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { + // files of components nested inside this dir belong to them, not to the component being added. + const nestedRootDirs = this.bitMap.getNestedRootDirs(relativeComponentPath); + const matches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { cwd: this.consumer.getPath(), nodir: true, // the workspace root is full of dotfiles that belong to it (.gitignore, .github/**). without // this, "bit add ." records an incomplete file-set that only the next rescan corrects, since // getFilesByDir() scans with dot: true. - dot: isWorkspaceRoot, - // node_modules is filtered out by gitIgnore below anyway, but enumerating it first is slow - // enough to matter at the workspace root. the rest are bit's own internals - see - // WORKSPACE_ROOT_IGNORE_LIST, which the rescan uses for the same reason. - ignore: isWorkspaceRoot ? ['**/node_modules/**', ...WORKSPACE_ROOT_IGNORE_LIST] : undefined, + dot: relativeComponentPath === WORKSPACE_ROOT_DIR, + // the same exclusions the rescan applies, see SCAN_IGNORE_LIST. + ignore: [...SCAN_IGNORE_LIST, ...nestedRootDirs.map((nestedRootDir) => `${nestedRootDir}/**`)], }); - // files of components nested inside this dir belong to them, not to the component being added. - const nestedRootDirs = this.bitMap.getNestedRootDirs(relativeComponentPath); - const matches = nestedRootDirs.length - ? allMatches.filter((match: PathOsBased) => { - const linuxMatch = pathNormalizeToLinux(match); - return !nestedRootDirs.some((nestedRootDir) => linuxMatch.startsWith(`${nestedRootDir}/`)); - }) - : allMatches; if (!matches.length) throw new EmptyDirectory(componentPath); @@ -691,26 +693,27 @@ you can add the directory these files are located at and it'll change the root d } function throwForExistingParentDir(bitMap: BitMap, relativeToConsumerPath: PathOsBased, addedId?: ComponentID) { + if (relativeToConsumerPath === WORKSPACE_ROOT_DIR) { + // only one component can own the workspace root. rejected before the (expensive) scan of the + // whole workspace, with a message that says which component already owns it. re-adding the same + // component is fine. the root component contains every other component by design, so there is no + // parent-dir conflict to check for it. + const currentOwner = bitMap.getComponentIdByRootPath(WORKSPACE_ROOT_DIR); + if (currentOwner && !addedId?.isEqual(currentOwner, { ignoreVersion: true })) { + throw new BitError( + `unable to track the workspace root, it is already tracked by "${currentOwner.toStringWithoutVersion()}"` + ); + } + return; + } const isParentDir = (parent: string) => { const relative = path.relative(parent, relativeToConsumerPath); return relative && !relative.startsWith('..') && !path.isAbsolute(relative); }; bitMap.components.forEach((componentMap) => { - if (!componentMap.rootDir) return; - if (componentMap.rootDir === WORKSPACE_ROOT_DIR) { - // only one component can own the workspace root. a second one is accepted here but fails - // .bitmap's duplicate-rootDir validation on the next load, so reject it now with a message - // that says which component already owns it. re-adding the same component is fine. - const isSameComponent = addedId?.isEqual(componentMap.id, { ignoreVersion: true }); - if (relativeToConsumerPath === WORKSPACE_ROOT_DIR && !isSameComponent) { - throw new BitError( - `unable to track the workspace root, it is already tracked by "${componentMap.id.toStringWithoutVersion()}"` - ); - } - // otherwise it is not a conflict: the workspace-root component contains every other component - // by design, and subtracts their root-dirs from its own file-set. - return; - } + // the workspace-root component contains every other component by design, and subtracts their + // root-dirs from its own file-set. + if (!componentMap.rootDir || componentMap.rootDir === WORKSPACE_ROOT_DIR) return; if (isParentDir(componentMap.rootDir)) { throw new ParentDirTracked( componentMap.rootDir, diff --git a/scopes/git/modules/ignore-file-reader/ignore.ts b/scopes/git/modules/ignore-file-reader/ignore.ts index d6add8a3cd8b..9d4dffb9b690 100644 --- a/scopes/git/modules/ignore-file-reader/ignore.ts +++ b/scopes/git/modules/ignore-file-reader/ignore.ts @@ -25,9 +25,15 @@ export async function getBitIgnoreFile(dir: string): Promise { return gitignore(fileContent); } +/** + * the patterns the user wrote: .bitignore when it exists, otherwise .gitignore. + */ +export async function retrieveUserIgnoreList(consumerRoot: string): Promise { + return (await isBitIgnoreFileExistsInDir(consumerRoot)) + ? getBitIgnoreFile(consumerRoot) + : getGitIgnoreFile(consumerRoot); +} + export async function retrieveIgnoreList(consumerRoot: string): Promise { - const userIgnoreList = (await isBitIgnoreFileExistsInDir(consumerRoot)) - ? await getBitIgnoreFile(consumerRoot) - : await getGitIgnoreFile(consumerRoot); - return [...userIgnoreList, ...IGNORE_LIST]; + return [...(await retrieveUserIgnoreList(consumerRoot)), ...IGNORE_LIST]; } diff --git a/scopes/git/modules/ignore-file-reader/index.ts b/scopes/git/modules/ignore-file-reader/index.ts index 99334b738b6d..b711e9470253 100644 --- a/scopes/git/modules/ignore-file-reader/index.ts +++ b/scopes/git/modules/ignore-file-reader/index.ts @@ -1 +1 @@ -export { BIT_IGNORE, getBitIgnoreFile, getGitIgnoreFile, retrieveIgnoreList } from './ignore'; +export { BIT_IGNORE, getBitIgnoreFile, getGitIgnoreFile, retrieveIgnoreList, retrieveUserIgnoreList } from './ignore'; diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 32779e5ccfd3..06159cf1d291 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -1537,10 +1537,9 @@ export class InstallMain { // package - it holds the workspace's own config files, nothing depends on it, and handing its // dir to the package manager makes it collide with the root project (pnpm resolves it to an // empty "file:" spec and fails to build the lockfile). - const installableComponents = components.filter( - (component) => this.workspace.componentDir(component.id) !== this.workspace.path + return ComponentMap.as(components, (component) => this.workspace.componentDir(component.id)).filter( + (componentDir) => componentDir !== this.workspace.path ); - return ComponentMap.as(installableComponents, (component) => this.workspace.componentDir(component.id)); } private async onRootAspectAddedSubscriber(_aspectId: ComponentID, inWs: boolean): Promise { diff --git a/scopes/workspace/workspace/workspace.ts b/scopes/workspace/workspace/workspace.ts index 90054fc915f7..f5e2ff90fbab 100644 --- a/scopes/workspace/workspace/workspace.ts +++ b/scopes/workspace/workspace/workspace.ts @@ -42,7 +42,7 @@ import type { LaneId } from '@teambit/lane-id'; import type { Consumer } from '@teambit/legacy.consumer'; import { loadConsumer } from '@teambit/legacy.consumer'; import type { GetBitMapComponentOptions } from '@teambit/legacy.bit-map'; -import { MissingBitMapComponent } from '@teambit/legacy.bit-map'; +import { fileContentsForVersioning, MissingBitMapComponent } from '@teambit/legacy.bit-map'; import type { InMemoryCache } from '@teambit/harmony.modules.in-memory-cache'; import { getMaxSizeForComponents, createInMemoryCache } from '@teambit/harmony.modules.in-memory-cache'; import type { LoadFailure } from '@teambit/harmony.modules.load-trace'; @@ -771,7 +771,9 @@ it's possible that the version ${component.id.version} belong to ${idStr.split(' const compDirAbs = path.join(this.path, compDir); const sourceFilesVinyls = bitMapEntry.files.map((file) => { const filePath = path.join(compDirAbs, file.relativePath); - return SourceFile.load(filePath, compDirAbs, this.path, {}); + const sourceFile = SourceFile.load(filePath, compDirAbs, this.path, {}); + sourceFile.contents = fileContentsForVersioning(bitMapEntry, file.relativePath, sourceFile.contents); + return sourceFile; }); const repo = this.scope.legacyScope.objects; const getModelFiles = async () => { From 189c57f39b0621ade6b7721431bd3f49202a1b6c Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 15:00:41 -0400 Subject: [PATCH 009/102] fix(bit-map): disable globby directory expansion in the scan, it broke git worktrees globby stats each ignore pattern relative to the process cwd before expanding it, and `.git/**` throws ENOTDIR where `.git` is a file. every pattern the scan passes is already an explicit glob. Co-Authored-By: Claude Fable 5.1 --- .../legacy/bit-map/component-map.spec.ts | 21 ++++++++++++++++--- components/legacy/bit-map/component-map.ts | 4 ++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index 376a834ab8da..0c3200809c8b 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -78,15 +78,30 @@ describe('getFilesByDir', function () { }); after(() => fs.remove(workspacePath)); + // bit runs with the workspace as its cwd. globby stats ignore patterns relative to the process + // cwd, so the `.git` pointer file is only exercised from inside the workspace. + const scanFromInsideTheWorkspace = async (dir: string, excludeDirs: string[]): Promise => { + const originalCwd = process.cwd(); + process.chdir(workspacePath); + try { + const gitIgnore = await getGitIgnoreHarmony(workspacePath); + const files = await getFilesByDir(dir, workspacePath, gitIgnore, excludeDirs); + return files.map((file) => file.relativePath).sort(); + } finally { + process.chdir(originalCwd); + } + }; + it('should own every file no other component claims, and skip the bit and git internals', async () => { - const gitIgnore = await getGitIgnoreHarmony(workspacePath); - const files = await getFilesByDir(WORKSPACE_ROOT_DIR, workspacePath, gitIgnore, ['packages/comp1']); - expect(files.map((file) => file.relativePath).sort()).to.deep.equal([ + expect(await scanFromInsideTheWorkspace(WORKSPACE_ROOT_DIR, ['packages/comp1'])).to.deep.equal([ '.bitmap', '.github/ci.yml', 'README.md', 'workspace.jsonc', ]); }); + it('should scan a nested component in a git worktree, where .git is a file', async () => { + expect(await scanFromInsideTheWorkspace('packages/comp1', [])).to.deep.equal(['index.ts']); + }); }); }); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index b9a3b6939615..052b92cdf1f8 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -434,6 +434,10 @@ export async function getFilesByDir( dot: true, onlyFiles: true, ignore: [...SCAN_IGNORE_LIST, ...excludeDirs.map((excludeDir) => `${excludeDir}/**`)], + // every pattern here is an explicit glob. with expansion on, globby stats each ignore pattern + // (relative to the process cwd, not to `cwd`) to decide whether to expand it, and `.git/**` + // throws ENOTDIR wherever `.git` is a file - every git worktree. + expandDirectories: false, }); if (!matches.length) throw new ComponentNotFoundInPath(dir); const filteredMatches: string[] = gitIgnore.filter(matches); From a6943c1ae41cbb4af87863f84d156df6db8c4094 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 15:18:10 -0400 Subject: [PATCH 010/102] fix(workspace-root): keep the root out of node_modules, and tighten the scans - the node-modules linker never links the workspace-root component (bit add linked it directly, bypassing the install filter, symlinking the workspace into its own node_modules) - the scan excludes git and bit internals at any depth, so an unclaimed nested repository or workspace does not hand its metadata to the root component - bit add applies the root-only generated-config list, like the rescan; excludes the paths of the same batch nested in the scanned dir; and no longer drops "." from a batch as a wildcard expansion of itself - the batch-track path passes the component id to the root-owner check, so re-tracking is a no-op Co-Authored-By: Claude Fable 5.1 --- .../legacy/bit-map/component-map.spec.ts | 6 +++ components/legacy/bit-map/component-map.ts | 21 ++++----- e2e/harmony/add-harmony.e2e.ts | 26 +++++++++++ scopes/component/tracker/add-components.ts | 44 +++++++++++++++---- .../node-modules-linker.ts | 9 +++- 5 files changed, 86 insertions(+), 20 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index 0c3200809c8b..cf38ceb1d9f4 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -74,6 +74,11 @@ describe('getFilesByDir', function () { [`${BIT_WORKSPACE_TMP_DIRNAME}/x`]: '', 'node_modules/dep/index.js': '', 'packages/comp1/index.ts': '', + // a vendored repository and a nested bit workspace that no component claims + 'vendor/lib/.git/HEAD': '', + 'vendor/lib/index.js': '', + 'nested/.bit/objects/bb': '', + 'nested/.bit.map.json': '', }); }); after(() => fs.remove(workspacePath)); @@ -97,6 +102,7 @@ describe('getFilesByDir', function () { '.bitmap', '.github/ci.yml', 'README.md', + 'vendor/lib/index.js', 'workspace.jsonc', ]); }); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 052b92cdf1f8..72a928af151b 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -53,11 +53,12 @@ export function isWorkspaceMapFile(relativePath: PathLinux): boolean { /** * excluded from every directory scan, before the ignore files are consulted. * node_modules is filtered by the ignore list later on anyway, but enumerating it first hurts - * performance dramatically. the rest are bit's own outputs at the workspace root: `.bit` (the local - * object store), `.git` and `.bitTmp` are outputs of versioning, not sources, `.bit.map.json` is the - * legacy location of the map itself, and `.git` is also a file in git worktrees and submodules (a - * pointer to the real git dir). they only exist at the root, so they only matter for the - * workspace-root component; for any other component they live outside its root-dir. + * performance dramatically. the rest are git's and bit's own internals: `.bit` (the local object + * store), `.git` and `.bitTmp` are outputs of versioning, not sources, `.bit.map.json` is the legacy + * location of the map itself, and `.git` is also a file in git worktrees and submodules (a pointer to + * the real git dir). they are matched at any depth: the workspace-root component scans the whole + * workspace, and a nested repository or bit workspace that no component claims must not hand its + * metadata to it. * * note that `.bitmap` is deliberately NOT here. it is the map of the workspace and a git-free * workspace has to be able to restore it, so the root component tracks it like any other file. @@ -67,11 +68,11 @@ export function isWorkspaceMapFile(relativePath: PathLinux): boolean { */ export const SCAN_IGNORE_LIST = [ '**/node_modules/**', - `${BIT_HIDDEN_DIR}/**`, - DOT_GIT_DIR, - `${DOT_GIT_DIR}/**`, - `${BIT_WORKSPACE_TMP_DIRNAME}/**`, - OLD_BIT_MAP, + `**/${BIT_HIDDEN_DIR}/**`, + `**/${DOT_GIT_DIR}`, + `**/${DOT_GIT_DIR}/**`, + `**/${BIT_WORKSPACE_TMP_DIRNAME}/**`, + `**/${OLD_BIT_MAP}`, ]; export type ComponentMapFile = { diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index cfa8cf40b997..091b81d304e8 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -65,6 +65,32 @@ describe('add command on Harmony', function () { it('should not claim the local scope directory', () => { expect(rootFiles.some((file) => file.startsWith('.bit/'))).to.be.false; }); + it('should not be linked into node_modules, unlike a regular component', () => { + // it is the workspace itself, not a package. linking it would symlink the workspace into its + // own node_modules, .bitmap included. + const nodeModules = path.join(helper.scopes.localPath, 'node_modules'); + expect(path.join(nodeModules, helper.general.getPackageNameByCompName('comp1', false))).to.be.a.path(); + expect(path.join(nodeModules, helper.general.getPackageNameByCompName('ws-root', false))).to.not.be.a.path(); + }); + }); + describe('adding the workspace root and a nested component in one command', () => { + let addedComponents: Array<{ id: string; files: string[] }>; + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('index.js', 'module.exports = {};\n'); + // a direct child ("bit add . comp1") is dropped from the batch as a wildcard expansion of ".", + // a pre-existing rule. a deeper one is added alongside the root. + helper.fs.outputFile('packages/comp1/index.js', 'module.exports = () => "comp1";\n'); + addedComponents = JSON.parse(helper.command.runCmd('bit add . packages/comp1 --json')).addedComponents; + }); + it('should leave the nested component files out of the root, already at add time', () => { + // the nested component is not in .bitmap yet when the root is scanned, so the batch itself has + // to provide the exclusion. otherwise the two own the same files until the next rescan. + expect(addedComponents).to.have.lengthOf(2); + const root = addedComponents.find((added) => !added.id.endsWith('comp1')); + expect(root?.files).to.include('index.js'); + expect(root?.files.some((file) => file.startsWith('packages/'))).to.be.false; + }); }); describe('workspace-root component and .bitmap', () => { before(() => { diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index d134f0f265f5..189088644c06 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -9,7 +9,12 @@ import { Analytics } from '@teambit/legacy.analytics'; import { ComponentID } from '@teambit/component-id'; import type { BitIdStr } from '@teambit/legacy-bit-id'; import { BitId } from '@teambit/legacy-bit-id'; -import { PACKAGE_JSON, VERSION_DELIMITER, AUTO_GENERATED_STAMP } from '@teambit/legacy.constants'; +import { + PACKAGE_JSON, + VERSION_DELIMITER, + AUTO_GENERATED_STAMP, + IGNORE_ROOT_ONLY_LIST, +} from '@teambit/legacy.constants'; import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { ComponentMap, @@ -519,7 +524,7 @@ you can add the directory these files are located at and it'll change the root d * e.g. bar/foo.js, the id would be bar/foo. * in case bitmap has already the same id, the complete id is taken from bitmap (see _getIdAccordingToExistingComponent) */ - async addOneComponent(componentPath: PathOsBased): Promise { + async addOneComponent(componentPath: PathOsBased, batchRootDirs: PathLinuxRelative[] = []): Promise { let finalBitId: ComponentID | undefined; // final id to use for bitmap file let idFromPath; if (this.id) { @@ -530,8 +535,16 @@ you can add the directory these files are located at and it'll change the root d const relativeComponentPath = this.consumer.getPathRelativeToConsumer(componentPath) || WORKSPACE_ROOT_DIR; this._throwForOutsideConsumer(relativeComponentPath); throwForExistingParentDir(this.bitMap, relativeComponentPath, finalBitId); - // files of components nested inside this dir belong to them, not to the component being added. - const nestedRootDirs = this.bitMap.getNestedRootDirs(relativeComponentPath); + // files of components nested inside this dir belong to them, not to the component being added - + // whether they are tracked already or added by the same command. + const nestedRootDirs = uniq([ + ...this.bitMap.getNestedRootDirs(relativeComponentPath), + ...batchRootDirs.filter( + (other) => + other !== relativeComponentPath && + (relativeComponentPath === WORKSPACE_ROOT_DIR || other.startsWith(`${relativeComponentPath}/`)) + ), + ]); const matches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { cwd: this.consumer.getPath(), nodir: true, @@ -545,7 +558,14 @@ you can add the directory these files are located at and it'll change the root d if (!matches.length) throw new EmptyDirectory(componentPath); - const filteredMatches = this.gitIgnore.filter(matches); + // the config files "bit ws-config write" generates are not source - the same rule the rescan + // applies (see getFilesByDir), so the add-time file-set matches the next rescan. + const generatedAtRoot = new Set( + IGNORE_ROOT_ONLY_LIST.map((file) => pathNormalizeToLinux(path.join(relativeComponentPath, file))) + ); + const filteredMatches = this.gitIgnore + .filter(matches) + .filter((match) => this.consumer.config.trackAllFiles || !generatedAtRoot.has(pathNormalizeToLinux(match))); if (!filteredMatches.length) { throw new NoFiles(matches); @@ -621,7 +641,8 @@ you can add the directory these files are located at and it'll change the root d _removeDirectoriesWhenTheirFilesAreAdded(componentPathsStats: PathsStats) { const allPaths = Object.keys(componentPathsStats); allPaths.forEach((componentPath) => { - const foundDir = allPaths.find((p) => p === path.dirname(componentPath)); + // the dirname of "." is "." itself - the workspace root is not a wildcard expansion of itself. + const foundDir = allPaths.find((p) => p !== componentPath && p === path.dirname(componentPath)); if (foundDir && componentPathsStats[foundDir]) { logger.debug(`add-components._removeDirectoriesWhenTheirFilesAreAdded, ignoring ${foundDir}`); delete componentPathsStats[foundDir]; @@ -671,9 +692,12 @@ you can add the directory these files are located at and it'll change the root d } async _tryAddingMultiple(componentPathsStats: PathsStats): Promise { + const batchRootDirs = Object.keys(componentPathsStats).map( + (onePath) => pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(onePath)) || WORKSPACE_ROOT_DIR + ); const addedP = Object.keys(componentPathsStats).map(async (onePath) => { try { - const addedComponent = await this.addOneComponent(onePath); + const addedComponent = await this.addOneComponent(onePath, batchRootDirs); return addedComponent; } catch (err: any) { if (!(err instanceof EmptyDirectory)) throw err; @@ -792,7 +816,9 @@ export async function addMultipleFromResolvedTrackData( const componentMaps = trackData.map((data) => { const { rootDir, files, componentName, defaultScope, mainFile, config } = data; if (path.isAbsolute(rootDir)) throw new BitError(`path is absolute, got ${rootDir}`); - throwForExistingParentDir(bitMap, rootDir); + const componentId = ComponentID.fromObject({ name: componentName }, defaultScope); + // re-tracking the workspace root with the same id is a no-op, not a second owner + throwForExistingParentDir(bitMap, rootDir, componentId); const filtered = gitIgnore.filter(files); if (!filtered.length) { @@ -804,7 +830,7 @@ export async function addMultipleFromResolvedTrackData( }); const componentMap = bitMap.addComponent({ - componentId: ComponentID.fromObject({ name: componentName }, defaultScope), + componentId, files: componentFiles, defaultScope, config, diff --git a/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts b/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts index d9ebc4bad58e..5aaf6604ad74 100644 --- a/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts +++ b/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts @@ -5,6 +5,7 @@ import { linkPkgsToRootComponents } from '@teambit/workspace.root-components'; import type { ComponentID } from '@teambit/component-id'; import { IS_WINDOWS, PACKAGE_JSON, SOURCE_DIR_SYMLINK_TO_NM } from '@teambit/legacy.constants'; import type { BitMap } from '@teambit/legacy.bit-map'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import { PackageJsonFile, DataToPersist, RemovePath, Symlink } from '@teambit/component.sources'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -47,7 +48,13 @@ export default class NodeModuleLinker { this.packageJsonCreated = false; } async link(): Promise { - this.components = this.components.filter((component) => this.bitMap.getComponentIfExist(component.id)); + // the workspace-root component (rootDir ".") is the workspace itself, not a package. linking it + // would symlink the workspace into its own node_modules, .bitmap included. the package manager + // side of the same rule is in InstallMain.getComponentsDirectory(). + this.components = this.components.filter((component) => { + const componentMap = this.bitMap.getComponentIfExist(component.id); + return componentMap && componentMap.rootDir !== WORKSPACE_ROOT_DIR; + }); const links = await this.getLinks(); const linksResults = this.getLinksResults(); From 30c946d5bb1748415f157ebe1b1af3fccbfa18e1 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 15:43:17 -0400 Subject: [PATCH 011/102] fix(workspace-root): track the root with the empty env as explicit config - bit add . writes the env into the root's .bitmap config the way bit env set does, and the envs aspect keeps its single default. the fallback only reached the paths that receive a component; the dependency-policy merge resolved the env from the extensions and fell back to the node env. - the versioned .bitmap drops "config" along with "version": a snap stores the config in the version and removes it from the map, so keeping it left the root modified after every first snap. - BitMap.addComponent validates the rootDir before touching an existing entry, so a rejected add leaves the map as it was. Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 7 ++++ components/legacy/bit-map/bit-map.ts | 27 ++++++++------ e2e/harmony/add-harmony.e2e.ts | 15 +++++++- scopes/component/tracker/add-components.ts | 37 ++++++++++++++++++- scopes/envs/envs/environments.main.runtime.ts | 30 +-------------- 5 files changed, 74 insertions(+), 42 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 964af170ad0b..2cfc2c879d5e 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -144,6 +144,7 @@ describe('BitMap', function () { defaultScope: 'my-org.demo', mainFile: 'index.ts', rootDir: 'comp1', + config: { 'teambit.envs/envs': { env: 'teambit.harmony/node' } }, }, '$schema-version': '17.0.0', }, @@ -159,6 +160,9 @@ describe('BitMap', function () { it('should empty the version, which changes on every snap', () => { expect(parsed.comp1.version).to.equal(''); }); + it('should drop the config, which a snap moves into the version and removes from the map', () => { + expect(parsed.comp1).to.not.have.property('config'); + }); it('should keep the scope, so cross-scope components survive a restore', () => { expect(parsed.comp1.scope).to.equal('my-scope'); }); @@ -186,6 +190,9 @@ describe('BitMap', function () { const addAnother = () => bitMap.addComponent({ ...componentParams, componentId: ComponentID.fromObject({ name: 'comp2' }, 'my-scope') }); expect(addAnother).to.throw('already used by another component'); + // and the rejected entry is not left behind in the map + expect(bitMap.components).to.have.lengthOf(1); + expect(bitMap.components[0].rootDir).to.equal('packages/comp1'); }); it('should let the same component be added again', async () => { const bitMap = await getBitmapInstance(); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 5e9a1c426225..a0d9911c91f2 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -111,7 +111,7 @@ export class BitMap { * the workspace-root component (rootDir ".") is the one exception - it is allowed to contain * other components. its file-set subtracts their root-dirs, so the two never claim the same file. */ - private throwForExistingParentDir({ id, rootDir }: ComponentMap) { + private throwForExistingParentDir(id: ComponentID, rootDir: PathLinuxRelative) { const isParentDir = (parent: string, child: string) => { const relative = path.relative(parent, child); return relative && !relative.startsWith('..'); @@ -756,11 +756,13 @@ export class BitMap { this.setComponent(componentId, newComponentMap); return newComponentMap; }; + // validated before an existing entry is touched, so a rejected add leaves the map as it was + const rootDirLinux = rootDir ? pathNormalizeToLinux(rootDir) : undefined; + if (rootDirLinux) this.throwForExistingParentDir(componentId, rootDirLinux); const componentMap = getOrCreateComponentMap(); componentMap.mainFile = mainFile; - if (rootDir) { - componentMap.rootDir = pathNormalizeToLinux(rootDir); - this.throwForExistingParentDir(componentMap); + if (rootDirLinux) { + componentMap.rootDir = rootDirLinux; } if (onLanesOnly) { componentMap.onLanesOnly = onLanesOnly; @@ -1079,11 +1081,12 @@ type OutputFileParams = { /** * the workspace-root component tracks `.bitmap` so a git-free workspace can be restored from the - * scope. the `version` of every entry changes on each snap - including the root component's own - * entry - so versioning it verbatim would leave that component modified immediately after every - * snap, forever, and never converge. only the durable part of the map is versioned: which components - * exist and where they live. the versions are restored from the component heads on import, which is - * the correct source for them anyway. + * scope. two fields of every entry change on a snap - including the root component's own entry - + * so versioning them verbatim would leave that component modified immediately after every snap, + * forever, and never converge: `version`, and `config`, which is the pending config - a snap stores + * it in the version and removes it from the map. only the durable part of the map is versioned: + * which components exist and where they live. the versions and the config are restored from the + * component heads on import, which is the correct source for them anyway. * * `scope` is deliberately kept: it changes once (on the first export) and is then stable, so it * costs one extra snap rather than perpetual drift. clearing it would lose the identity of @@ -1095,8 +1098,10 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string const parsed = json.parse(rawContent, undefined, true) as Record | undefined; if (!parsed) return rawContent; Object.keys(parsed).forEach((key) => { - if (key === SCHEMA_FIELD || key === LANE_KEY) return; - if (parsed[key]?.version !== undefined) parsed[key].version = ''; + const entry = parsed[key]; + if (key === SCHEMA_FIELD || key === LANE_KEY || !entry || typeof entry !== 'object') return; + if (entry.version !== undefined) entry.version = ''; + delete entry.config; }); return formatBitMapFile(parsed); } diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 091b81d304e8..0efc6e820fa8 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -262,11 +262,24 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# workspace root\n'); helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); }); - it('should default to the empty env, not to the regular default env', () => { + it('should be tracked with the empty env, not the regular default env', () => { // it is a bag of the workspace's own config files - nothing compiles it, tests it, or // imports it as a package. the regular default env would give it a toolchain it can't use. expect(helper.env.getComponentEnv('ws-root')).to.equal('teambit.harmony/empty-env'); }); + it('should record that env in .bitmap as explicit config', () => { + // explicit, so every path that resolves an env or a dependency policy sees the same answer + expect(helper.bitMap.read()['ws-root'].config['teambit.envs/envs'].env).to.equal('teambit.harmony/empty-env'); + }); + it('should get no dependency policy from an env, unlike a regular component', () => { + const envPolicyOf = (name: string): string[] => + helper.command + .showAspectConfig(name, 'teambit.dependencies/dependency-resolver') + .data.policy.filter((entry) => entry.source === 'env') + .map((entry) => entry.dependencyId); + expect(envPolicyOf('comp1')).to.include('@types/node'); + expect(envPolicyOf('ws-root')).to.deep.equal([]); + }); it('should leave the env of a regular component alone', () => { expect(helper.env.getComponentEnv('comp1')).to.equal('teambit.harmony/node'); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 189088644c06..3b4e7401f695 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -14,6 +14,7 @@ import { VERSION_DELIMITER, AUTO_GENERATED_STAMP, IGNORE_ROOT_ONLY_LIST, + Extensions, } from '@teambit/legacy.constants'; import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { @@ -348,7 +349,10 @@ export default class AddComponents { componentId: new ComponentID(componentId._legacy, defaultScope), files: component.files, defaultScope, - config: this.config, + config: + rootDir === WORKSPACE_ROOT_DIR + ? configForWorkspaceRoot(foundComponentFromBitMap?.config, this.config) + : this.config, mainFile, // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! override: this.override, @@ -802,6 +806,31 @@ async function isAutoGeneratedFile(filePath: PathOsBased): Promise { return line.includes(AUTO_GENERATED_STAMP); } +/** + * the env the workspace-root component is tracked with. hardcoded, the same way the envs aspect + * names its default env - the tracker does not depend on the env aspects. + */ +const WORKSPACE_ROOT_ENV = 'teambit.harmony/empty-env'; + +/** + * the workspace-root component (rootDir ".") is a bag of config files, not a source component: + * nothing compiles it, nothing tests it, and nothing imports it as a package. the regular default + * env would give it a compiler and a dependency policy it can never satisfy, so it is tracked with + * the empty env - as explicit config, so every path that resolves an env or a dependency policy + * sees the same answer. an env configured by the caller, or set later with "bit env set", wins. + */ +export function configForWorkspaceRoot(existingConfig?: Config, addedConfig?: Config): Config { + const configuredEnv = addedConfig?.[Extensions.envs] ?? existingConfig?.[Extensions.envs]; + if (configuredEnv) return { ...existingConfig, ...addedConfig }; + // the same two entries "bit env set" writes: the env aspect itself, and the env selection + return { + ...existingConfig, + ...addedConfig, + [WORKSPACE_ROOT_ENV]: {}, + [Extensions.envs]: { env: WORKSPACE_ROOT_ENV }, + }; +} + export async function addMultipleFromResolvedTrackData( workspace: Workspace, trackData: ResolvedTrackData[] @@ -819,6 +848,10 @@ export async function addMultipleFromResolvedTrackData( const componentId = ComponentID.fromObject({ name: componentName }, defaultScope); // re-tracking the workspace root with the same id is a no-op, not a second owner throwForExistingParentDir(bitMap, rootDir, componentId); + const isWorkspaceRoot = rootDir === WORKSPACE_ROOT_DIR; + const existingConfig = isWorkspaceRoot + ? bitMap.getComponentIfExist(componentId, { ignoreVersion: true })?.config + : undefined; const filtered = gitIgnore.filter(files); if (!filtered.length) { @@ -833,7 +866,7 @@ export async function addMultipleFromResolvedTrackData( componentId, files: componentFiles, defaultScope, - config, + config: isWorkspaceRoot ? configForWorkspaceRoot(existingConfig, config) : config, mainFile, rootDir, }); diff --git a/scopes/envs/envs/environments.main.runtime.ts b/scopes/envs/envs/environments.main.runtime.ts index 23b87bab4e3f..368765bc9e69 100644 --- a/scopes/envs/envs/environments.main.runtime.ts +++ b/scopes/envs/envs/environments.main.runtime.ts @@ -26,7 +26,6 @@ import { head, uniq } from 'lodash'; import type { WorkerMain } from '@teambit/worker'; import { WorkerAspect } from '@teambit/worker'; import { ComponentID } from '@teambit/component-id'; -import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { EnvService } from './services'; import type { Environment } from './environment'; import { EnvsAspect } from './environments.aspect'; @@ -106,17 +105,6 @@ export type Descriptor = RegularCompDescriptor | EnvCompDescriptor; export const DEFAULT_ENV = 'teambit.harmony/node'; -/** - * the workspace-root component (rootDir ".") owns the files no other component claims - workspace - * config, CI config, .bitmap, README, LICENSE. it is a bag of config files, not a source component: - * nothing compiles it, nothing tests it, and nothing imports it as a package. defaulting it to the - * regular default env gives it a compiler and a dependency policy it can never satisfy. - * - * hardcoded rather than imported from the aspect, same as DEFAULT_ENV above - empty-env depends on - * this aspect, so importing it back here would be circular. - */ -export const DEFAULT_ENV_FOR_WORKSPACE_ROOT = 'teambit.harmony/empty-env'; - export class EnvsMain { /** * Envs that are failed to load @@ -241,20 +229,6 @@ export class EnvsMain { return new EnvDefinition(DEFAULT_ENV, defaultEnv); } - /** - * the default env to fall back to when the component has no env configured on it. it is only - * different from getDefaultEnv() for the workspace-root component, see - * DEFAULT_ENV_FOR_WORKSPACE_ROOT. note this is a *fallback* - an env explicitly set on the - * component (`bit env set`) is resolved earlier and always wins. - */ - getDefaultEnvForComponent(component: Component): EnvDefinition { - if (component.state._consumer?.componentMap?.rootDir !== WORKSPACE_ROOT_DIR) return this.getDefaultEnv(); - const emptyEnv = this.envSlot.get(DEFAULT_ENV_FOR_WORKSPACE_ROOT); - // empty-env is a core aspect, but be defensive: a missing env must not break loading. - if (!emptyEnv) return this.getDefaultEnv(); - return new EnvDefinition(DEFAULT_ENV_FOR_WORKSPACE_ROOT, emptyEnv); - } - getCoreEnvsIds(): string[] { return [ 'teambit.harmony/aspect', @@ -684,7 +658,7 @@ export class EnvsMain { }); ids = uniq(ids); const envId = await this.findFirstEnv(ids); - const finalId = envId || this.getDefaultEnvForComponent(component).id; + const finalId = envId || this.getDefaultEnv().id; return ComponentID.fromString(finalId); } @@ -751,7 +725,7 @@ export class EnvsMain { this.envIds.add(envDefFromList.id); return envDefFromList; } - return this.getDefaultEnvForComponent(component); + return this.getDefaultEnv(); } /** From e9b233fcf4e599d54a63cb6bb04b4a5f5a213582 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 16:13:41 -0400 Subject: [PATCH 012/102] fix(tracker): re-add the workspace root without its name, escape nested root-dirs in scans Co-Authored-By: Claude Fable 5.1 --- .../legacy/bit-map/component-map.spec.ts | 7 +++++- components/legacy/bit-map/component-map.ts | 25 +++++++++++++++---- components/legacy/bit-map/index.ts | 2 +- e2e/harmony/add-harmony.e2e.ts | 4 +++ scopes/component/tracker/add-components.ts | 14 +++++++---- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index cf38ceb1d9f4..e33d24caf0b6 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -74,6 +74,10 @@ describe('getFilesByDir', function () { [`${BIT_WORKSPACE_TMP_DIRNAME}/x`]: '', 'node_modules/dep/index.js': '', 'packages/comp1/index.ts': '', + // a nested component whose root-dir is glob syntax (a Next.js route), next to a dir that + // the unescaped pattern "app/[slug]" would match instead + 'app/[slug]/page.ts': '', + 'app/l/index.ts': '', // a vendored repository and a nested bit workspace that no component claims 'vendor/lib/.git/HEAD': '', 'vendor/lib/index.js': '', @@ -98,10 +102,11 @@ describe('getFilesByDir', function () { }; it('should own every file no other component claims, and skip the bit and git internals', async () => { - expect(await scanFromInsideTheWorkspace(WORKSPACE_ROOT_DIR, ['packages/comp1'])).to.deep.equal([ + expect(await scanFromInsideTheWorkspace(WORKSPACE_ROOT_DIR, ['packages/comp1', 'app/[slug]'])).to.deep.equal([ '.bitmap', '.github/ci.yml', 'README.md', + 'app/l/index.ts', 'vendor/lib/index.js', 'workspace.jsonc', ]); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 72a928af151b..b928c2b5a8ea 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -62,11 +62,8 @@ export function isWorkspaceMapFile(relativePath: PathLinux): boolean { * * note that `.bitmap` is deliberately NOT here. it is the map of the workspace and a git-free * workspace has to be able to restore it, so the root component tracks it like any other file. - * - * exported so that `bit add` builds its initial file-set from the same list the rescan uses - - * otherwise the two disagree about what the workspace-root component owns. */ -export const SCAN_IGNORE_LIST = [ +const SCAN_IGNORE_LIST = [ '**/node_modules/**', `**/${BIT_HIDDEN_DIR}/**`, `**/${DOT_GIT_DIR}`, @@ -75,6 +72,24 @@ export const SCAN_IGNORE_LIST = [ `**/${OLD_BIT_MAP}`, ]; +/** + * the ignore patterns for scanning a directory. `excludeDirs` are the root-dirs of the components + * nested inside it, whose files belong to them. they are literal paths, so their glob metacharacters + * are escaped: a Next.js route dir like "app/[slug]" is a valid root-dir, and read as a pattern it + * would exclude the wrong directories (`app/l`) rather than itself. + * + * shared with `bit add`, so its initial file-set is built from the same exclusions the rescan uses - + * otherwise the two disagree about what the workspace-root component owns. + */ +export function getScanIgnorePatterns(excludeDirs: PathLinux[] = []): string[] { + return [...SCAN_IGNORE_LIST, ...excludeDirs.map((excludeDir) => `${escapeGlobPath(excludeDir)}/**`)]; +} + +/** backslash-escapes the characters that globby and glob read as pattern syntax */ +function escapeGlobPath(literalPath: PathLinux): string { + return literalPath.replace(/[*?[\]{}()!@+|]/g, '\\$&'); +} + export type ComponentMapFile = { relativePath: PathLinux; /** @@ -434,7 +449,7 @@ export async function getFilesByDir( cwd: consumerPath, dot: true, onlyFiles: true, - ignore: [...SCAN_IGNORE_LIST, ...excludeDirs.map((excludeDir) => `${excludeDir}/**`)], + ignore: getScanIgnorePatterns(excludeDirs), // every pattern here is an explicit glob. with expansion on, globby stats each ignore pattern // (relative to the process cwd, not to `cwd`) to decide whether to expand it, and `.git/**` // throws ENOTDIR wherever `.git` is a file - every git worktree. diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 6da861c52918..c86ad402e614 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -15,8 +15,8 @@ export { ComponentMap, Config, getIgnoreListHarmony, + getScanIgnorePatterns, isWorkspaceMapFile, NextVersion, - SCAN_IGNORE_LIST, WORKSPACE_ROOT_DIR, } from './component-map'; diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 0efc6e820fa8..d2f6690eab32 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -163,6 +163,10 @@ describe('add command on Harmony', function () { helper.fs.outputFile('extra.md', 'extra\n'); expect(() => helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' })).to.not.throw(); }); + it('should allow re-adding it without repeating its name', () => { + expect(() => helper.command.addComponent('.', { m: 'README.md' })).to.not.throw(); + expect(Object.keys(helper.bitMap.readComponentsMapOnly())).to.deep.equal(['ws-root']); + }); it('should reject a second component claiming the workspace root', () => { const cmd = () => helper.command.addComponent('.', { i: 'another-root', m: 'README.md' }); expect(cmd).to.throw('already tracked by'); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 3b4e7401f695..3fa9778bdb77 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -20,8 +20,8 @@ import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { ComponentMap, getIgnoreListHarmony, + getScanIgnorePatterns, MissingMainFile, - SCAN_IGNORE_LIST, WORKSPACE_ROOT_DIR, } from '@teambit/legacy.bit-map'; import { DuplicateIds, EmptyDirectory, ExcludedMainFile, MainFileIsDir, NoFiles, PathsNotExist } from './exceptions'; @@ -538,7 +538,12 @@ you can add the directory these files are located at and it'll change the root d // to "." so it is a real rootDir rather than a falsy one. const relativeComponentPath = this.consumer.getPathRelativeToConsumer(componentPath) || WORKSPACE_ROOT_DIR; this._throwForOutsideConsumer(relativeComponentPath); - throwForExistingParentDir(this.bitMap, relativeComponentPath, finalBitId); + // the component this add is for: the one the user named, tracked already or not, or else the + // one already tracking this dir. for the workspace root, this is what tells a re-add from a + // second owner. + const idOfTrackDir = this._getIdAccordingToTrackDir(relativeComponentPath); + const idToAdd = this.id ? finalBitId : idOfTrackDir; + throwForExistingParentDir(this.bitMap, relativeComponentPath, idToAdd || undefined); // files of components nested inside this dir belong to them, not to the component being added - // whether they are tracked already or added by the same command. const nestedRootDirs = uniq([ @@ -556,8 +561,8 @@ you can add the directory these files are located at and it'll change the root d // this, "bit add ." records an incomplete file-set that only the next rescan corrects, since // getFilesByDir() scans with dot: true. dot: relativeComponentPath === WORKSPACE_ROOT_DIR, - // the same exclusions the rescan applies, see SCAN_IGNORE_LIST. - ignore: [...SCAN_IGNORE_LIST, ...nestedRootDirs.map((nestedRootDir) => `${nestedRootDir}/**`)], + // the same exclusions the rescan applies, see getFilesByDir(). + ignore: getScanIgnorePatterns(nestedRootDirs), }); if (!matches.length) throw new EmptyDirectory(componentPath); @@ -583,7 +588,6 @@ you can add the directory these files are located at and it'll change the root d const absoluteComponentPath = pathNormalizeToLinux(path.resolve(componentPath)); const splitPath = absoluteComponentPath.split('/'); const lastDir = splitPath[splitPath.length - 1]; - const idOfTrackDir = this._getIdAccordingToTrackDir(componentPath); if (!finalBitId) { if (this.id) { const bitId = BitId.parse(this.id, false); From 1ca46ad0c9b285f94507627cedfa1ea6dec18023 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 16:28:58 -0400 Subject: [PATCH 013/102] fix(tracker): keep the workspace-root main file safe on bulk tracking and case-only renames Co-Authored-By: Claude Fable 5.1 --- scopes/component/tracker/add-components.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 3fa9778bdb77..2332716722f4 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -262,11 +262,12 @@ export default class AddComponents { const ownedByWorkspaceRoot = Boolean( workspaceRootMap && existingIdOfFile?.isEqualWithoutVersion(workspaceRootMap.id) ); + // the ownership lookup above is case-insensitive, so this comparison is too if ( workspaceRootMap && idOfFileIsDifferent && ownedByWorkspaceRoot && - file.relativePath === workspaceRootMap.mainFile + file.relativePath.toLowerCase() === workspaceRootMap.mainFile.toLowerCase() ) { throw new BitError( `unable to add "${file.relativePath}" to "${parsedBitId.toString()}", it is the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` @@ -846,6 +847,7 @@ export async function addMultipleFromResolvedTrackData( workspace.consumer.config.trackAllFiles ); const gitIgnore = ignore().add(ignoreList); + const batchRootDirs = trackData.map((data) => data.rootDir); const componentMaps = trackData.map((data) => { const { rootDir, files, componentName, defaultScope, mainFile, config } = data; if (path.isAbsolute(rootDir)) throw new BitError(`path is absolute, got ${rootDir}`); @@ -856,8 +858,18 @@ export async function addMultipleFromResolvedTrackData( const existingConfig = isWorkspaceRoot ? bitMap.getComponentIfExist(componentId, { ignoreVersion: true })?.config : undefined; + // files of components nested inside the workspace root belong to them, not to the root - whether + // tracked already or by this same call. the rule addOneComponent() and the rescan apply, so the + // map stays loadable: a root main-file inside a nested component fails validation before the map + // is written, not on the next load. + const nestedRootDirs = isWorkspaceRoot + ? uniq([...bitMap.getNestedRootDirs(rootDir), ...batchRootDirs.filter((dir) => dir !== WORKSPACE_ROOT_DIR)]) + : []; + const ownedFiles = files.filter( + (file) => !nestedRootDirs.some((nestedRootDir) => pathNormalizeToLinux(file).startsWith(`${nestedRootDir}/`)) + ); - const filtered = gitIgnore.filter(files); + const filtered = gitIgnore.filter(ownedFiles); if (!filtered.length) { throw new NoFiles(files); } From 3bf4c683bfa2c2ce711c2cf243c058dc9d9249db Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 16:39:35 -0400 Subject: [PATCH 014/102] fix(workspace-root): require --override for user files on a root import, skip the root in node_modules cleanup Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 23 ++++++++++++++++ .../component-writer.main.runtime.ts | 26 ++++++++++++------- scopes/component/remove/remove-components.ts | 8 +++++- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index d2f6690eab32..1f6f3025cf4a 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -139,6 +139,11 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# workspace root\n'); helper.fs.outputFile('untracked-by-bit.txt', 'not a component file\n'); helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + // a dependency that happens to share the package name the root's id derives + helper.fs.outputFile( + path.join('node_modules', helper.general.getPackageNameByCompName('ws-root', false), 'index.js'), + '' + ); helper.command.removeComponent('ws-root --silent'); }); it('should not delete the workspace', () => { @@ -152,6 +157,11 @@ describe('add command on Harmony', function () { it('should keep the other component tracked', () => { expect(helper.bitMap.read()).to.have.property('comp1'); }); + it('should not delete a dependency that shares its derived package name', () => { + // it was never linked, so the node_modules cleanup has nothing of it to remove + const packageDir = path.join('node_modules', helper.general.getPackageNameByCompName('ws-root', false)); + expect(path.join(helper.scopes.localPath, packageDir, 'index.js')).to.be.a.path(); + }); }); describe('re-adding and double-adding the workspace root', () => { before(() => { @@ -238,6 +248,19 @@ describe('add command on Harmony', function () { expect(helper.bitMap.read()).to.not.have.property('comp1'); }); }); + describe('importing it onto the root of a fresh workspace that has its own files', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.fs.outputFile('README.md', '# my own readme\n'); + }); + it('should refuse to overwrite them without --override, even though nothing is tracked yet', () => { + // only the files "bit init" generated are meant to be landed on. the rest of the root is the user's. + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); + expect(cmd).to.throw('use --override'); + expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# my own readme\n'); + }); + }); describe('importing it onto the root of a workspace that already tracks components', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 04da7993c62f..1e749ba836bd 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -20,7 +20,7 @@ import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { isDir, isDirEmptySync, pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ComponentMap } from '@teambit/legacy.bit-map'; import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; -import { COMPONENT_CONFIG_FILE_NAME } from '@teambit/legacy.constants'; +import { COMPONENT_CONFIG_FILE_NAME, WORKSPACE_JSONC } from '@teambit/legacy.constants'; import { DataToPersist } from '@teambit/component.sources'; import type { ConfigMergerMain, WorkspaceConfigUpdateResult } from '@teambit/config-merger'; import { ConfigMergerAspect } from '@teambit/config-merger'; @@ -338,26 +338,32 @@ to move all component files to a different directory, run bit remove and then bi * the workspace root is never empty - it holds .bit, .bitmap and workspace.jsonc - so "not empty" * says nothing about a conflict there. the target is only reachable for a workspace-root component. * restoring a git-free workspace from its scope starts from `bit init`, and the root files are meant - * to land on top of the freshly initialized ones. a workspace that already tracks components is not - * being restored: its workspace.jsonc and root files would be overwritten silently, so it takes - * --override like any other occupied directory. + * to land on top of the ones it generated. every other existing file at the root is the user's own, + * whether or not the workspace tracks components yet, so overwriting it takes --override like any + * other occupied directory. a file identical to its incoming copy is not overwritten in any sense + * that matters, and this is what lets the files `bit init` generates (agent instructions, mcp config) + * meet their own versioned copies. workspace.jsonc is the exception: the freshly initialized one is + * meant to be replaced by the versioned one. */ private throwForOccupiedWorkspaceRoot(component: ConsumerComponent, opts: ManyComponentsWriterParams) { if (!opts.throwForExistingDir) return; const isFreshWorkspace = this.consumer.bitMap.components.every((componentMap) => componentMap.id.isEqualWithoutVersion(component.id) ); - if (isFreshWorkspace) return; + const generatedByInit = isFreshWorkspace ? [WORKSPACE_JSONC] : []; const filesToOverwrite = component.files - .map((file) => pathNormalizeToLinux(file.relative)) - .filter( - (relativePath) => !isWorkspaceMapFile(relativePath) && fs.existsSync(this.consumer.toAbsolutePath(relativePath)) - ); + .filter((file) => { + const relativePath = pathNormalizeToLinux(file.relative); + if (isWorkspaceMapFile(relativePath) || generatedByInit.includes(relativePath)) return false; + const absolutePath = this.consumer.toAbsolutePath(relativePath); + return fs.existsSync(absolutePath) && !fs.readFileSync(absolutePath).equals(file.contents); + }) + .map((file) => pathNormalizeToLinux(file.relative)); if (!filesToOverwrite.length) return; const shown = filesToOverwrite.slice(0, 10).join(', '); const rest = filesToOverwrite.length > 10 ? ` and ${filesToOverwrite.length - 10} more` : ''; throw new BitError( - `unable to import "${component.id.toString()}" to the workspace root, this workspace already tracks other components and the import would overwrite ${shown}${rest}. + `unable to import "${component.id.toString()}" to the workspace root, it would overwrite ${shown}${rest}. use --override to overwrite them` ); } diff --git a/scopes/component/remove/remove-components.ts b/scopes/component/remove/remove-components.ts index e09e9e316bbd..7d74bea79db6 100644 --- a/scopes/component/remove/remove-components.ts +++ b/scopes/component/remove/remove-components.ts @@ -10,6 +10,7 @@ import { logger } from '@teambit/legacy.logger'; import { Http } from '@teambit/scope.network'; import { Remotes } from '@teambit/scope.remotes'; import { deleteComponentsFiles } from './delete-component-files'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { ComponentsList } from '@teambit/legacy.component-list'; import type { RemovedObjects } from '@teambit/legacy.scope'; import pMapSeries from 'p-map-series'; @@ -186,7 +187,12 @@ If you understand the risks and wish to proceed with the removal, please use the export async function removeComponentsFromNodeModules(consumer: Consumer, components: ConsumerComponent[]) { logger.debug(`removeComponentsFromNodeModules: ${components.map((c) => c.id.toString()).join(', ')}`); - const pathsToRemoveWithNulls = components.map((c) => { + // the workspace-root component is never linked (it is the workspace, not a package), so there is + // nothing of it to remove, and the path its id derives may belong to a dependency of the same name. + const linkedComponents = components.filter( + (c) => consumer.bitMap.getComponentIfExist(c.id, { ignoreVersion: true })?.rootDir !== WORKSPACE_ROOT_DIR + ); + const pathsToRemoveWithNulls = linkedComponents.map((c) => { return getNodeModulesPathOfComponent({ ...c, id: c.id }); }); const pathsToRemove = compact(pathsToRemoveWithNulls); From b08e4ef4cf30ae493dbfa998ad1625c9bc731efa Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 16:50:36 -0400 Subject: [PATCH 015/102] fix(workspace-root): keep .bitmap in the add-time file-set, skip the root on eject install, type trackAllFiles Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 2 ++ scopes/component/tracker/add-components.ts | 6 +++++- scopes/harmony/config/workspace-config.ts | 1 + scopes/workspace/eject/components-ejector.ts | 6 +++++- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 1f6f3025cf4a..7f54c8ee13f3 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -185,6 +185,8 @@ describe('add command on Harmony', function () { helper.fs.outputFile('.npmrc', 'registry=https://example.com\n'); const output = helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); expect(output).to.have.string('.npmrc'); + // its auto-generated banner must not get it dropped, the rescan tracks it + expect(output).to.have.string('.bitmap'); }); }); describe('adding a nested component that holds the main file of the workspace root', () => { diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 2332716722f4..092c8caec426 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -21,6 +21,7 @@ import { ComponentMap, getIgnoreListHarmony, getScanIgnorePatterns, + isWorkspaceMapFile, MissingMainFile, WORKSPACE_ROOT_DIR, } from '@teambit/legacy.bit-map'; @@ -249,11 +250,14 @@ export default class AddComponents { // root subtracts the new root-dir from its own file-set on the next scan. the one file it cannot // give away is its main file - without it, it fails to load from the next scan on. const workspaceRootMap = this.bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); + const isWorkspaceRoot = component.trackDir === WORKSPACE_ROOT_DIR; const componentFilesP = files.map(async (file: ComponentMapFile) => { // $FlowFixMe null is removed later on const filePath = path.join(consumerPath, file.relativePath); + // the workspace map carries the auto-generated banner, but the root component tracks it on + // purpose (see isWorkspaceMapFile), and the rescan keeps it. the add-time file-set does too. const isAutoGenerated = await isAutoGeneratedFile(filePath); - if (isAutoGenerated) { + if (isAutoGenerated && !(isWorkspaceRoot && isWorkspaceMapFile(file.relativePath))) { return null; } const caseSensitive = false; diff --git a/scopes/harmony/config/workspace-config.ts b/scopes/harmony/config/workspace-config.ts index 575f17036e5d..6be26501e1c7 100644 --- a/scopes/harmony/config/workspace-config.ts +++ b/scopes/harmony/config/workspace-config.ts @@ -42,6 +42,7 @@ export type WorkspaceExtensionProps = { defaultDirectory?: string; components?: ComponentScopeDirMap; ignoredFiles?: string[]; + trackAllFiles?: boolean; }; export type PackageManagerClients = 'npm' | 'yarn' | undefined; diff --git a/scopes/workspace/eject/components-ejector.ts b/scopes/workspace/eject/components-ejector.ts index 59a87718989e..2eb8b781dc21 100644 --- a/scopes/workspace/eject/components-ejector.ts +++ b/scopes/workspace/eject/components-ejector.ts @@ -136,7 +136,11 @@ export class ComponentsEjector { } getPackagesToInstall(): string[] { - return this.componentsToEject.map((c) => componentIdToPackageName(c)); + // the workspace-root component is not a package (never linked, never published), ejecting it only + // untracks it. the name its id derives may belong to an unrelated package. + return this.componentsToEject + .filter((c) => c.componentMap?.rootDir !== WORKSPACE_ROOT_DIR) + .map((c) => componentIdToPackageName(c)); } _buildExceptionMessageWithRollbackData(action: string): string { From 74c694d03eae7c158dc5fc1f1d3b6b5fdd4c88c2 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 11 Sep 2026 17:17:23 -0400 Subject: [PATCH 016/102] fix(workspace-root): treat symlinks and dirs as import conflicts, apply the generated-file rule on bulk tracking Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 20 +++++++++++++++++++ .../component-writer.main.runtime.ts | 6 +++++- scopes/component/tracker/add-components.ts | 17 ++++++++-------- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 7f54c8ee13f3..732a6b5801eb 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -1,4 +1,5 @@ import chai from 'chai'; +import fs from 'fs-extra'; import path from 'path'; import { ParentDirTracked, AddingIndividualFiles } from '@teambit/tracker'; import { Helper } from '@teambit/legacy.e2e-helper'; @@ -263,6 +264,25 @@ describe('add command on Harmony', function () { expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# my own readme\n'); }); }); + describe('importing it onto the root with a directory or a dangling symlink in the way', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + }); + it('should report a directory at a file path as a conflict rather than fail reading it', () => { + fs.mkdirSync(path.join(helper.scopes.localPath, 'README.md')); + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); + expect(cmd).to.throw('use --override'); + }); + it('should report a dangling symlink as a conflict rather than write through it', () => { + fs.rmdirSync(path.join(helper.scopes.localPath, 'README.md')); + const target = path.join(helper.scopes.localPath, 'missing-target'); + fs.symlinkSync(target, path.join(helper.scopes.localPath, 'README.md')); + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); + expect(cmd).to.throw('use --override'); + expect(target).to.not.be.a.path(); + }); + }); describe('importing it onto the root of a workspace that already tracks components', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 1e749ba836bd..52f1ef983170 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -356,7 +356,11 @@ to move all component files to a different directory, run bit remove and then bi const relativePath = pathNormalizeToLinux(file.relative); if (isWorkspaceMapFile(relativePath) || generatedByInit.includes(relativePath)) return false; const absolutePath = this.consumer.toAbsolutePath(relativePath); - return fs.existsSync(absolutePath) && !fs.readFileSync(absolutePath).equals(file.contents); + // lstat rather than exists: a symlink in the way, dangling or not, is a conflict and not a path + // to write through, and so is a directory. only a regular file is compared with the incoming copy. + const stat = fs.lstatSync(absolutePath, { throwIfNoEntry: false }); + if (!stat) return false; + return !stat.isFile() || !fs.readFileSync(absolutePath).equals(file.contents); }) .map((file) => pathNormalizeToLinux(file.relative)); if (!filesToOverwrite.length) return; diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 092c8caec426..6aaff81c37bf 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -845,11 +845,8 @@ export async function addMultipleFromResolvedTrackData( trackData: ResolvedTrackData[] ): Promise { const bitMap = workspace.consumer.bitMap; - const ignoreList = await getIgnoreListHarmony( - workspace.path, - workspace.consumer.config.ignoredFiles, - workspace.consumer.config.trackAllFiles - ); + const { ignoredFiles, trackAllFiles } = workspace.consumer.config; + const ignoreList = await getIgnoreListHarmony(workspace.path, ignoredFiles, trackAllFiles); const gitIgnore = ignore().add(ignoreList); const batchRootDirs = trackData.map((data) => data.rootDir); const componentMaps = trackData.map((data) => { @@ -869,9 +866,13 @@ export async function addMultipleFromResolvedTrackData( const nestedRootDirs = isWorkspaceRoot ? uniq([...bitMap.getNestedRootDirs(rootDir), ...batchRootDirs.filter((dir) => dir !== WORKSPACE_ROOT_DIR)]) : []; - const ownedFiles = files.filter( - (file) => !nestedRootDirs.some((nestedRootDir) => pathNormalizeToLinux(file).startsWith(`${nestedRootDir}/`)) - ); + // and the config files "bit ws-config write" generates are not source, the rule the rescan applies + // (see getFilesByDir), so one of them cannot be the main file the next load drops. + const ownedFiles = files.filter((file) => { + const relativePath = pathNormalizeToLinux(file); + if (nestedRootDirs.some((nestedRootDir) => relativePath.startsWith(`${nestedRootDir}/`))) return false; + return trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(relativePath); + }); const filtered = gitIgnore.filter(ownedFiles); if (!filtered.length) { From 6e31b80cc06f9f83755d684eae3d1bc7a208aff0 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 09:59:24 -0400 Subject: [PATCH 017/102] fix(workspace-root): honor nested ignore files and skip nested maps in the root scan, portable collision check Co-Authored-By: Claude Fable 5.1 --- .../legacy/bit-map/component-map.spec.ts | 11 ++++ components/legacy/bit-map/component-map.ts | 62 +++++++++++++++++-- components/legacy/bit-map/index.ts | 1 + .../component-writer.main.runtime.ts | 16 ++++- scopes/component/tracker/add-components.ts | 14 +++-- 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index e33d24caf0b6..2971daccb0b6 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -83,6 +83,14 @@ describe('getFilesByDir', function () { 'vendor/lib/index.js': '', 'nested/.bit/objects/bb': '', 'nested/.bit.map.json': '', + 'nested/.bitmap': '', + // an ignore file below the root applies to its directory, like git: unanchored patterns at any + // depth below it, anchored ones to it + 'docs/.gitignore': 'build/\n/local.env\n', + 'docs/index.md': '', + 'docs/build/out.html': '', + 'docs/local.env': '', + 'docs/nested/local.env': '', }); }); after(() => fs.remove(workspacePath)); @@ -107,6 +115,9 @@ describe('getFilesByDir', function () { '.github/ci.yml', 'README.md', 'app/l/index.ts', + 'docs/.gitignore', + 'docs/index.md', + 'docs/nested/local.env', 'vendor/lib/index.js', 'workspace.jsonc', ]); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index b928c2b5a8ea..f5147d9ada49 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -13,6 +13,7 @@ import { IGNORE_ROOT_ONLY_LIST, ALWAYS_IGNORE_LIST, IGNORE_LIST, + GIT_IGNORE, } from '@teambit/legacy.constants'; import { ValidationError } from '@teambit/legacy.cli.error'; import { logger } from '@teambit/legacy.logger'; @@ -72,6 +73,13 @@ const SCAN_IGNORE_LIST = [ `**/${OLD_BIT_MAP}`, ]; +/** + * a bit workspace nested in the scanned tree that no component claims must not hand its map to the + * workspace-root component: restored, it would turn that directory into a broken workspace (a map + * without its scope). only the root's own map is tracked, see isWorkspaceMapFile. + */ +const NESTED_WORKSPACE_MAP = `*/**/${BIT_MAP}`; + /** * the ignore patterns for scanning a directory. `excludeDirs` are the root-dirs of the components * nested inside it, whose files belong to them. they are literal paths, so their glob metacharacters @@ -81,8 +89,54 @@ const SCAN_IGNORE_LIST = [ * shared with `bit add`, so its initial file-set is built from the same exclusions the rescan uses - * otherwise the two disagree about what the workspace-root component owns. */ -export function getScanIgnorePatterns(excludeDirs: PathLinux[] = []): string[] { - return [...SCAN_IGNORE_LIST, ...excludeDirs.map((excludeDir) => `${escapeGlobPath(excludeDir)}/**`)]; +export function getScanIgnorePatterns(dir: PathLinux, excludeDirs: PathLinux[] = []): string[] { + return [ + ...SCAN_IGNORE_LIST, + ...(dir === WORKSPACE_ROOT_DIR ? [NESTED_WORKSPACE_MAP] : []), + ...excludeDirs.map((excludeDir) => `${escapeGlobPath(excludeDir)}/**`), + ]; +} + +/** + * git applies each .gitignore to the directory it sits in, so the scan of the workspace root - the one + * scan that spans directories no component claims - honors the ignore files below the root as well. + * their patterns are rebased to their directory: a pattern with no slash before its end matches at any + * depth below it (`build/` becomes `docs/**\/build/`), any other is anchored to it (`/local.env` becomes + * `docs/local.env`). a .bitignore beside a .gitignore wins, as at the root. nested components are + * subtracted before this runs, so their ignore files are theirs to apply. + */ +export async function filterByNestedIgnoreFiles( + dir: PathLinux, + consumerPath: string, + relativePaths: PathLinux[] +): Promise { + if (dir !== WORKSPACE_ROOT_DIR) return relativePaths; + const ignoreFileByDir = new Map(); + relativePaths.forEach((relativePath) => { + const name = path.basename(relativePath); + if (name !== GIT_IGNORE && name !== BIT_IGNORE) return; + const fileDir = path.dirname(relativePath); + if (fileDir === '.') return; // the root's own ignore file is in the workspace ignore list already + if (name === BIT_IGNORE || !ignoreFileByDir.has(fileDir)) ignoreFileByDir.set(fileDir, name); + }); + if (!ignoreFileByDir.size) return relativePaths; + const patternsPerDir = await Promise.all( + Array.from(ignoreFileByDir, async ([fileDir, name]) => { + const absoluteDir = path.join(consumerPath, fileDir); + const patterns = name === BIT_IGNORE ? await getBitIgnoreFile(absoluteDir) : await getGitIgnoreFile(absoluteDir); + return patterns.map((pattern) => rebaseIgnorePattern(pattern, fileDir)); + }) + ); + const patterns = ([] as string[]).concat(...patternsPerDir); + return ignore().add(patterns).filter(relativePaths); +} + +function rebaseIgnorePattern(pattern: string, dir: PathLinux): string { + const negated = pattern.startsWith('!'); + const body = negated ? pattern.slice(1) : pattern; + const anchored = body.slice(0, -1).includes('/'); + const rebased = anchored ? pathJoinLinux(dir, body.replace(/^\//, '')) : pathJoinLinux(dir, '**', body); + return negated ? `!${rebased}` : rebased; } /** backslash-escapes the characters that globby and glob read as pattern syntax */ @@ -449,14 +503,14 @@ export async function getFilesByDir( cwd: consumerPath, dot: true, onlyFiles: true, - ignore: getScanIgnorePatterns(excludeDirs), + ignore: getScanIgnorePatterns(dir, excludeDirs), // every pattern here is an explicit glob. with expansion on, globby stats each ignore pattern // (relative to the process cwd, not to `cwd`) to decide whether to expand it, and `.git/**` // throws ENOTDIR wherever `.git` is a file - every git worktree. expandDirectories: false, }); if (!matches.length) throw new ComponentNotFoundInPath(dir); - const filteredMatches: string[] = gitIgnore.filter(matches); + const filteredMatches: string[] = await filterByNestedIgnoreFiles(dir, consumerPath, gitIgnore.filter(matches)); // the paths are relative to the workspace. make them relative to the component's root-dir. const relativePathsLinux = filteredMatches.map((match) => pathRelativeLinux(dir, match)); // the config files "bit ws-config write" generates are not source - unless the workspace declares that diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index c86ad402e614..fdc783698af6 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -14,6 +14,7 @@ export { ComponentMapFile, ComponentMap, Config, + filterByNestedIgnoreFiles, getIgnoreListHarmony, getScanIgnorePatterns, isWorkspaceMapFile, diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 52f1ef983170..bf0f674ca394 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -358,9 +358,12 @@ to move all component files to a different directory, run bit remove and then bi const absolutePath = this.consumer.toAbsolutePath(relativePath); // lstat rather than exists: a symlink in the way, dangling or not, is a conflict and not a path // to write through, and so is a directory. only a regular file is compared with the incoming copy. - const stat = fs.lstatSync(absolutePath, { throwIfNoEntry: false }); + const stat = lstatIfExists(absolutePath); if (!stat) return false; - return !stat.isFile() || !fs.readFileSync(absolutePath).equals(file.contents); + // compared byte for byte through latin1, which maps each byte to one character. Buffer.equals is + // avoided because its signature differs between @types/node majors, and an aspect build type-checks + // this file with whichever version its capsule resolves. + return !stat.isFile() || fs.readFileSync(absolutePath, 'latin1') !== file.contents.toString('latin1'); }) .map((file) => pathNormalizeToLinux(file.relative)); if (!filesToOverwrite.length) return; @@ -494,3 +497,12 @@ export function incrementPathRecursively(p: string, allPaths: string[]) { } return newPath; } + +function lstatIfExists(absolutePath: string): fs.Stats | undefined { + try { + return fs.lstatSync(absolutePath); + } catch (err: any) { + if (err.code === 'ENOENT') return undefined; + throw err; + } +} diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 6aaff81c37bf..62d0168968ea 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -19,6 +19,7 @@ import { import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { ComponentMap, + filterByNestedIgnoreFiles, getIgnoreListHarmony, getScanIgnorePatterns, isWorkspaceMapFile, @@ -567,7 +568,7 @@ you can add the directory these files are located at and it'll change the root d // getFilesByDir() scans with dot: true. dot: relativeComponentPath === WORKSPACE_ROOT_DIR, // the same exclusions the rescan applies, see getFilesByDir(). - ignore: getScanIgnorePatterns(nestedRootDirs), + ignore: getScanIgnorePatterns(relativeComponentPath, nestedRootDirs), }); if (!matches.length) throw new EmptyDirectory(componentPath); @@ -577,9 +578,14 @@ you can add the directory these files are located at and it'll change the root d const generatedAtRoot = new Set( IGNORE_ROOT_ONLY_LIST.map((file) => pathNormalizeToLinux(path.join(relativeComponentPath, file))) ); - const filteredMatches = this.gitIgnore - .filter(matches) - .filter((match) => this.consumer.config.trackAllFiles || !generatedAtRoot.has(pathNormalizeToLinux(match))); + const matchesNotIgnored = await filterByNestedIgnoreFiles( + relativeComponentPath, + this.consumer.getPath(), + this.gitIgnore.filter(matches).map(pathNormalizeToLinux) + ); + const filteredMatches = matchesNotIgnored.filter( + (match) => this.consumer.config.trackAllFiles || !generatedAtRoot.has(match) + ); if (!filteredMatches.length) { throw new NoFiles(matches); From 6b25177193ea98325df548a08e4b60ae850d402b Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 10:16:41 -0400 Subject: [PATCH 018/102] fix(workspace-root): keep ignore options on an empty map, directory-only nested patterns, normalize bulk root-dirs Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 8 ++++++++ components/legacy/bit-map/bit-map.ts | 12 +++++++----- components/legacy/bit-map/component-map.spec.ts | 5 ++++- components/legacy/bit-map/component-map.ts | 4 +++- e2e/harmony/stash.e2e.ts | 3 +++ scopes/component/tracker/add-components.ts | 9 ++++++--- 6 files changed, 31 insertions(+), 10 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 2cfc2c879d5e..fb9c25d623e6 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -222,4 +222,12 @@ describe('BitMap', function () { expect(fileContentsForVersioning(nestedMap, '.bitmap', rawBitmap)).to.equal(rawBitmap); }); }); + describe('loading a workspace that has no .bitmap yet', () => { + it('should keep the ignore options, they apply to the scans of the components added next', async () => { + const bitMap = await BitMap.load(__dirname, '', ['*.bak'], true); + expect(bitMap.components).to.have.lengthOf(0); + expect(bitMap.ignoredFiles).to.deep.equal(['*.bak']); + expect(bitMap.trackAllFiles).to.be.true; + }); + }); }); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index a0d9911c91f2..d2dcb19ee22d 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -187,13 +187,15 @@ export class BitMap { ): Promise { const { currentLocation, defaultLocation } = BitMap.getBitMapLocation(dirPath); const mapFileContent = BitMap.loadRawSync(dirPath); - if (!mapFileContent || !currentLocation) { - return new BitMap(dirPath, defaultLocation, CURRENT_BITMAP_SCHEMA); - } - const bitMap = BitMap.loadFromContentWithoutLoadingFiles(mapFileContent, currentLocation, dirPath, defaultScope); + // the ignore rules apply to every scan, including the ones of a map that starts empty: the first + // component tracked in a fresh workspace is rescanned by the same process. + const bitMap = + !mapFileContent || !currentLocation + ? new BitMap(dirPath, defaultLocation, CURRENT_BITMAP_SCHEMA) + : BitMap.loadFromContentWithoutLoadingFiles(mapFileContent, currentLocation, dirPath, defaultScope); bitMap.ignoredFiles = ignoredFiles; bitMap.trackAllFiles = trackAllFiles; - await bitMap.loadFiles(); + if (mapFileContent && currentLocation) await bitMap.loadFiles(); return bitMap; } diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index 2971daccb0b6..376a3151890f 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -86,11 +86,13 @@ describe('getFilesByDir', function () { 'nested/.bitmap': '', // an ignore file below the root applies to its directory, like git: unanchored patterns at any // depth below it, anchored ones to it - 'docs/.gitignore': 'build/\n/local.env\n', + 'docs/.gitignore': 'build/\n/local.env\ntmp/\n', 'docs/index.md': '', 'docs/build/out.html': '', 'docs/local.env': '', 'docs/nested/local.env': '', + // "tmp/" ignores directories only. this is a file + 'docs/tmp': '', }); }); after(() => fs.remove(workspacePath)); @@ -118,6 +120,7 @@ describe('getFilesByDir', function () { 'docs/.gitignore', 'docs/index.md', 'docs/nested/local.env', + 'docs/tmp', 'vendor/lib/index.js', 'workspace.jsonc', ]); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index f5147d9ada49..3376fd2e7580 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -135,7 +135,9 @@ function rebaseIgnorePattern(pattern: string, dir: PathLinux): string { const negated = pattern.startsWith('!'); const body = negated ? pattern.slice(1) : pattern; const anchored = body.slice(0, -1).includes('/'); - const rebased = anchored ? pathJoinLinux(dir, body.replace(/^\//, '')) : pathJoinLinux(dir, '**', body); + // a trailing slash means "directories only". the join drops it, so it is put back. + const dirOnly = body.endsWith('/') ? '/' : ''; + const rebased = (anchored ? pathJoinLinux(dir, body.replace(/^\//, '')) : pathJoinLinux(dir, '**', body)) + dirOnly; return negated ? `!${rebased}` : rebased; } diff --git a/e2e/harmony/stash.e2e.ts b/e2e/harmony/stash.e2e.ts index 33edd2521204..534d3f296d69 100644 --- a/e2e/harmony/stash.e2e.ts +++ b/e2e/harmony/stash.e2e.ts @@ -132,6 +132,9 @@ describe('bit stash command', function () { const bitMap = helper.bitMap.read(); expect(bitMap).to.have.property('comp1'); }); + it('should re-create it with its rootDir, which is what its files are loaded from', () => { + expect(helper.bitMap.read().comp1.rootDir).to.equal('comp1'); + }); }); }); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 62d0168968ea..124a94c7731c 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -854,10 +854,13 @@ export async function addMultipleFromResolvedTrackData( const { ignoredFiles, trackAllFiles } = workspace.consumer.config; const ignoreList = await getIgnoreListHarmony(workspace.path, ignoredFiles, trackAllFiles); const gitIgnore = ignore().add(ignoreList); - const batchRootDirs = trackData.map((data) => data.rootDir); + // normalized once, the way the map stores it, so "./" is the workspace root here as well as there + const normalizeRootDir = (rootDir: string): PathLinuxRelative => pathNormalizeToLinux(path.normalize(rootDir)); + const batchRootDirs = trackData.map((data) => normalizeRootDir(data.rootDir)); const componentMaps = trackData.map((data) => { - const { rootDir, files, componentName, defaultScope, mainFile, config } = data; - if (path.isAbsolute(rootDir)) throw new BitError(`path is absolute, got ${rootDir}`); + const { files, componentName, defaultScope, mainFile, config } = data; + if (path.isAbsolute(data.rootDir)) throw new BitError(`path is absolute, got ${data.rootDir}`); + const rootDir = normalizeRootDir(data.rootDir); const componentId = ComponentID.fromObject({ name: componentName }, defaultScope); // re-tracking the workspace root with the same id is a no-op, not a second owner throwForExistingParentDir(bitMap, rootDir, componentId); From 2320d9741f5eeb4a10c584bef05eb685d8853765 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 10:34:53 -0400 Subject: [PATCH 019/102] fix(workspace-root): evaluate nested ignore files with the root ones in git order, match bulk files by workspace path Co-Authored-By: Claude Fable 5.1 --- .../legacy/bit-map/component-map.spec.ts | 8 ++- components/legacy/bit-map/component-map.ts | 51 ++++++++++++------- components/legacy/bit-map/index.ts | 2 +- scopes/component/tracker/add-components.ts | 14 +++-- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index 376a3151890f..d48a84cb87ef 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -66,6 +66,7 @@ describe('getFilesByDir', function () { workspacePath = await createWorkspace('bit-workspace-root-scan-', { 'README.md': '', '.bitmap': '', + '.gitignore': 'docs/*.txt\n', 'workspace.jsonc': '', '.github/ci.yml': '', // a git worktree or submodule: .git is a pointer file, not a directory @@ -86,8 +87,11 @@ describe('getFilesByDir', function () { 'nested/.bitmap': '', // an ignore file below the root applies to its directory, like git: unanchored patterns at any // depth below it, anchored ones to it - 'docs/.gitignore': 'build/\n/local.env\ntmp/\n', + // and a nested negation re-includes a file the root's rule excluded, evaluated in git's order + 'docs/.gitignore': 'build/\n/local.env\ntmp/\n!keep.txt\n', 'docs/index.md': '', + 'docs/notes.txt': '', + 'docs/keep.txt': '', 'docs/build/out.html': '', 'docs/local.env': '', 'docs/nested/local.env': '', @@ -115,10 +119,12 @@ describe('getFilesByDir', function () { expect(await scanFromInsideTheWorkspace(WORKSPACE_ROOT_DIR, ['packages/comp1', 'app/[slug]'])).to.deep.equal([ '.bitmap', '.github/ci.yml', + '.gitignore', 'README.md', 'app/l/index.ts', 'docs/.gitignore', 'docs/index.md', + 'docs/keep.txt', 'docs/nested/local.env', 'docs/tmp', 'vendor/lib/index.js', diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 3376fd2e7580..b1e4b6143447 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -98,19 +98,31 @@ export function getScanIgnorePatterns(dir: PathLinux, excludeDirs: PathLinux[] = } /** - * git applies each .gitignore to the directory it sits in, so the scan of the workspace root - the one - * scan that spans directories no component claims - honors the ignore files below the root as well. - * their patterns are rebased to their directory: a pattern with no slash before its end matches at any - * depth below it (`build/` becomes `docs/**\/build/`), any other is anchored to it (`/local.env` becomes - * `docs/local.env`). a .bitignore beside a .gitignore wins, as at the root. nested components are - * subtracted before this runs, so their ignore files are theirs to apply. + * applies the workspace ignore rules (`gitIgnore`) to the scanned paths. git applies each .gitignore to + * the directory it sits in, so the scan of the workspace root - the one scan that spans directories no + * component claims - honors the ignore files below the root as well, evaluated together with the + * root's in git's precedence order: a nested pattern comes after the root's, so its negation + * re-includes a file the root excluded. only ignore files that are not themselves ignored are + * consulted, since git does not descend into an ignored directory. nested patterns are rebased to + * their directory: a pattern with no slash before its end matches at any depth below it (`build/` + * becomes `docs/**\/build/`), any other is anchored to it (`/local.env` becomes `docs/local.env`). + * a .bitignore beside a .gitignore wins, as at the root. nested components are subtracted before + * this runs, so their ignore files are theirs to apply. */ -export async function filterByNestedIgnoreFiles( +export async function filterByIgnoreFiles( dir: PathLinux, consumerPath: string, + gitIgnore: any, relativePaths: PathLinux[] ): Promise { - if (dir !== WORKSPACE_ROOT_DIR) return relativePaths; + const filteredByRoot: PathLinux[] = gitIgnore.filter(relativePaths); + if (dir !== WORKSPACE_ROOT_DIR) return filteredByRoot; + const nestedPatterns = await getNestedIgnorePatterns(consumerPath, filteredByRoot); + if (!nestedPatterns.length) return filteredByRoot; + return ignore().add(gitIgnore).add(nestedPatterns).filter(relativePaths); +} + +async function getNestedIgnorePatterns(consumerPath: string, relativePaths: PathLinux[]): Promise { const ignoreFileByDir = new Map(); relativePaths.forEach((relativePath) => { const name = path.basename(relativePath); @@ -119,7 +131,7 @@ export async function filterByNestedIgnoreFiles( if (fileDir === '.') return; // the root's own ignore file is in the workspace ignore list already if (name === BIT_IGNORE || !ignoreFileByDir.has(fileDir)) ignoreFileByDir.set(fileDir, name); }); - if (!ignoreFileByDir.size) return relativePaths; + if (!ignoreFileByDir.size) return []; const patternsPerDir = await Promise.all( Array.from(ignoreFileByDir, async ([fileDir, name]) => { const absoluteDir = path.join(consumerPath, fileDir); @@ -127,8 +139,7 @@ export async function filterByNestedIgnoreFiles( return patterns.map((pattern) => rebaseIgnorePattern(pattern, fileDir)); }) ); - const patterns = ([] as string[]).concat(...patternsPerDir); - return ignore().add(patterns).filter(relativePaths); + return ([] as string[]).concat(...patternsPerDir); } function rebaseIgnorePattern(pattern: string, dir: PathLinux): string { @@ -512,7 +523,7 @@ export async function getFilesByDir( expandDirectories: false, }); if (!matches.length) throw new ComponentNotFoundInPath(dir); - const filteredMatches: string[] = await filterByNestedIgnoreFiles(dir, consumerPath, gitIgnore.filter(matches)); + const filteredMatches: string[] = await filterByIgnoreFiles(dir, consumerPath, gitIgnore, matches); // the paths are relative to the workspace. make them relative to the component's root-dir. const relativePathsLinux = filteredMatches.map((match) => pathRelativeLinux(dir, match)); // the config files "bit ws-config write" generates are not source - unless the workspace declares that @@ -524,11 +535,17 @@ export async function getFilesByDir( // from a sub-directory would otherwise look for the ignore file in the wrong place - and // getBitIgnoreFile() does not swallow ENOENT, so it throws rather than falling back. const ignoreFileDir = path.join(consumerPath, dir); - const bitOrGitIgnore = filteredByIgnoredFromRoot.includes(BIT_IGNORE) - ? await getBitIgnoreFile(ignoreFileDir) - : await getGitIgnoreFile(ignoreFileDir); - const filteredByBitIgnore = bitOrGitIgnore - ? ignore().add(bitOrGitIgnore).filter(filteredByIgnoredFromRoot) + // the component's own ignore file, applied to its files. for the workspace root that file is the + // workspace's, already part of `gitIgnore` and evaluated above together with the nested ones - + // applied again on its own it would undo their negations. + const ownIgnoreFile = + dir === WORKSPACE_ROOT_DIR + ? [] + : filteredByIgnoredFromRoot.includes(BIT_IGNORE) + ? await getBitIgnoreFile(ignoreFileDir) + : await getGitIgnoreFile(ignoreFileDir); + const filteredByBitIgnore = ownIgnoreFile.length + ? ignore().add(ownIgnoreFile).filter(filteredByIgnoredFromRoot) : filteredByIgnoredFromRoot; if (!filteredByBitIgnore.length) throw new IgnoredDirectory(dir); return filteredByBitIgnore.map((relativePath) => ({ diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index fdc783698af6..635610a70762 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -14,7 +14,7 @@ export { ComponentMapFile, ComponentMap, Config, - filterByNestedIgnoreFiles, + filterByIgnoreFiles, getIgnoreListHarmony, getScanIgnorePatterns, isWorkspaceMapFile, diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 124a94c7731c..fd61d2bf348d 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -19,7 +19,7 @@ import { import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { ComponentMap, - filterByNestedIgnoreFiles, + filterByIgnoreFiles, getIgnoreListHarmony, getScanIgnorePatterns, isWorkspaceMapFile, @@ -578,10 +578,11 @@ you can add the directory these files are located at and it'll change the root d const generatedAtRoot = new Set( IGNORE_ROOT_ONLY_LIST.map((file) => pathNormalizeToLinux(path.join(relativeComponentPath, file))) ); - const matchesNotIgnored = await filterByNestedIgnoreFiles( + const matchesNotIgnored = await filterByIgnoreFiles( relativeComponentPath, this.consumer.getPath(), - this.gitIgnore.filter(matches).map(pathNormalizeToLinux) + this.gitIgnore, + matches.map(pathNormalizeToLinux) ); const filteredMatches = matchesNotIgnored.filter( (match) => this.consumer.config.trackAllFiles || !generatedAtRoot.has(match) @@ -883,7 +884,12 @@ export async function addMultipleFromResolvedTrackData( return trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(relativePath); }); - const filtered = gitIgnore.filter(ownedFiles); + // the ignore rules are written against the workspace root, so a file is matched by its + // workspace-relative path, then mapped back to the component-relative one the map stores. + const workspaceRelative = ownedFiles.map((file) => path.posix.join(rootDir, pathNormalizeToLinux(file))); + const filtered: string[] = gitIgnore + .filter(workspaceRelative) + .map((file) => (isWorkspaceRoot ? file : path.posix.relative(rootDir, file))); if (!filtered.length) { throw new NoFiles(files); } From e3aace0dc2db81ebaad1580791205705f1d59e33 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 10:48:49 -0400 Subject: [PATCH 020/102] fix(workspace-root): keep bit-owned exclusions non-negatable, never scan a map at a nested component root Co-Authored-By: Claude Fable 5.1 --- .../legacy/bit-map/component-map.spec.ts | 8 ++++++-- components/legacy/bit-map/component-map.ts | 20 +++++++++++++------ scopes/component/tracker/add-components.ts | 3 ++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index d48a84cb87ef..ba869a5c216a 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -75,6 +75,8 @@ describe('getFilesByDir', function () { [`${BIT_WORKSPACE_TMP_DIRNAME}/x`]: '', 'node_modules/dep/index.js': '', 'packages/comp1/index.ts': '', + // a map at a nested component's root is never its file (that dir would be a nested workspace) + 'packages/comp1/.bitmap': '', // a nested component whose root-dir is glob syntax (a Next.js route), next to a dir that // the unescaped pattern "app/[slug]" would match instead 'app/[slug]/page.ts': '', @@ -87,11 +89,13 @@ describe('getFilesByDir', function () { 'nested/.bitmap': '', // an ignore file below the root applies to its directory, like git: unanchored patterns at any // depth below it, anchored ones to it - // and a nested negation re-includes a file the root's rule excluded, evaluated in git's order - 'docs/.gitignore': 'build/\n/local.env\ntmp/\n!keep.txt\n', + // and a nested negation re-includes a file the root's rule excluded, evaluated in git's order, + // but never one of the files bit always excludes + 'docs/.gitignore': 'build/\n/local.env\ntmp/\n!keep.txt\n!.env\n', 'docs/index.md': '', 'docs/notes.txt': '', 'docs/keep.txt': '', + 'docs/.env': 'SECRET=1\n', 'docs/build/out.html': '', 'docs/local.env': '', 'docs/nested/local.env': '', diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index b1e4b6143447..6ee490f8c068 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -76,7 +76,9 @@ const SCAN_IGNORE_LIST = [ /** * a bit workspace nested in the scanned tree that no component claims must not hand its map to the * workspace-root component: restored, it would turn that directory into a broken workspace (a map - * without its scope). only the root's own map is tracked, see isWorkspaceMapFile. + * without its scope). only the root's own map is tracked, see isWorkspaceMapFile - a map at any + * other component's root is never tracked either, so what a component versions is what a write + * lands, the live map excepted. */ const NESTED_WORKSPACE_MAP = `*/**/${BIT_MAP}`; @@ -92,7 +94,7 @@ const NESTED_WORKSPACE_MAP = `*/**/${BIT_MAP}`; export function getScanIgnorePatterns(dir: PathLinux, excludeDirs: PathLinux[] = []): string[] { return [ ...SCAN_IGNORE_LIST, - ...(dir === WORKSPACE_ROOT_DIR ? [NESTED_WORKSPACE_MAP] : []), + dir === WORKSPACE_ROOT_DIR ? NESTED_WORKSPACE_MAP : `${escapeGlobPath(dir)}/${BIT_MAP}`, ...excludeDirs.map((excludeDir) => `${escapeGlobPath(excludeDir)}/**`), ]; } @@ -107,19 +109,25 @@ export function getScanIgnorePatterns(dir: PathLinux, excludeDirs: PathLinux[] = * their directory: a pattern with no slash before its end matches at any depth below it (`build/` * becomes `docs/**\/build/`), any other is anchored to it (`/local.env` becomes `docs/local.env`). * a .bitignore beside a .gitignore wins, as at the root. nested components are subtracted before - * this runs, so their ignore files are theirs to apply. + * this runs, so their ignore files are theirs to apply. the rules bit owns (`.env`, node_modules, and + * the generated files unless `trackAllFiles`) are applied last, on their own, so no negation + * re-includes them. */ export async function filterByIgnoreFiles( dir: PathLinux, consumerPath: string, gitIgnore: any, - relativePaths: PathLinux[] + relativePaths: PathLinux[], + trackAllFiles = false ): Promise { const filteredByRoot: PathLinux[] = gitIgnore.filter(relativePaths); if (dir !== WORKSPACE_ROOT_DIR) return filteredByRoot; const nestedPatterns = await getNestedIgnorePatterns(consumerPath, filteredByRoot); if (!nestedPatterns.length) return filteredByRoot; - return ignore().add(gitIgnore).add(nestedPatterns).filter(relativePaths); + const filteredByUserRules: PathLinux[] = ignore().add(gitIgnore).add(nestedPatterns).filter(relativePaths); + return ignore() + .add(trackAllFiles ? ALWAYS_IGNORE_LIST : IGNORE_LIST) + .filter(filteredByUserRules); } async function getNestedIgnorePatterns(consumerPath: string, relativePaths: PathLinux[]): Promise { @@ -523,7 +531,7 @@ export async function getFilesByDir( expandDirectories: false, }); if (!matches.length) throw new ComponentNotFoundInPath(dir); - const filteredMatches: string[] = await filterByIgnoreFiles(dir, consumerPath, gitIgnore, matches); + const filteredMatches: string[] = await filterByIgnoreFiles(dir, consumerPath, gitIgnore, matches, trackAllFiles); // the paths are relative to the workspace. make them relative to the component's root-dir. const relativePathsLinux = filteredMatches.map((match) => pathRelativeLinux(dir, match)); // the config files "bit ws-config write" generates are not source - unless the workspace declares that diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index fd61d2bf348d..4447b7f3d26d 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -582,7 +582,8 @@ you can add the directory these files are located at and it'll change the root d relativeComponentPath, this.consumer.getPath(), this.gitIgnore, - matches.map(pathNormalizeToLinux) + matches.map(pathNormalizeToLinux), + this.consumer.config.trackAllFiles ); const filteredMatches = matchesNotIgnored.filter( (match) => this.consumer.config.trackAllFiles || !generatedAtRoot.has(match) From 8543f90926cd76bfbbc25235b31fe44e0c965b4e Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:01:57 -0400 Subject: [PATCH 021/102] fix(workspace-root): scan the root on bulk tracking, refuse ordinary components at "." Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/index.ts | 1 + e2e/harmony/add-harmony.e2e.ts | 11 +++ .../component-writer.main.runtime.ts | 17 ++++ scopes/component/tracker/add-components.ts | 80 +++++++++++-------- 4 files changed, 77 insertions(+), 32 deletions(-) diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 635610a70762..b321c91e14a5 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -15,6 +15,7 @@ export { ComponentMap, Config, filterByIgnoreFiles, + getFilesByDir, getIgnoreListHarmony, getScanIgnorePatterns, isWorkspaceMapFile, diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 732a6b5801eb..7acd87b07eef 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -283,6 +283,17 @@ describe('add command on Harmony', function () { expect(target).to.not.be.a.path(); }); }); + describe('importing an ordinary component onto the root', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + }); + it('should refuse, only a workspace-root component may own "."', () => { + // it would own every unclaimed file from the next scan on, and drop out of install and link + const cmd = () => helper.command.importComponentWithoutInstall('comp1', '--path .'); + expect(cmd).to.throw('not a workspace-root component'); + }); + }); describe('importing it onto the root of a workspace that already tracks components', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index bf0f674ca394..9a50e12663b7 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -279,6 +279,7 @@ export class ComponentWriterMain { ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) || WORKSPACE_ROOT_DIR : this.consumer.composeRelativeComponentPath(component.id); + if (componentRootDir === WORKSPACE_ROOT_DIR) this.throwForNonWorkspaceRootComponent(component); // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the @@ -334,6 +335,22 @@ to move all component files to a different directory, run bit remove and then bi return componentMap.rootDir === componentDirRelative; } + /** + * only a workspace-root component may be written to ".". any other component written there would own + * every unclaimed file in the workspace from the next scan on, and drop out of install and link. a + * workspace-root component is told by the workspace map it versions (see isWorkspaceMapFile): no + * other component carries one at its root, its scan never hands it one. + */ + private throwForNonWorkspaceRootComponent(component: ConsumerComponent) { + const versionsTheWorkspaceMap = component.files.some((file) => + isWorkspaceMapFile(pathNormalizeToLinux(file.relative)) + ); + if (versionsTheWorkspaceMap) return; + throw new BitError( + `unable to import "${component.id.toString()}" to the workspace root, it is not a workspace-root component. use --path to write it to a directory` + ); + } + /** * the workspace root is never empty - it holds .bit, .bitmap and workspace.jsonc - so "not empty" * says nothing about a conflict there. the target is only reachable for a workspace-root component. diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 4447b7f3d26d..418cd36f078f 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -20,6 +20,7 @@ import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { ComponentMap, filterByIgnoreFiles, + getFilesByDir, getIgnoreListHarmony, getScanIgnorePatterns, isWorkspaceMapFile, @@ -859,7 +860,8 @@ export async function addMultipleFromResolvedTrackData( // normalized once, the way the map stores it, so "./" is the workspace root here as well as there const normalizeRootDir = (rootDir: string): PathLinuxRelative => pathNormalizeToLinux(path.normalize(rootDir)); const batchRootDirs = trackData.map((data) => normalizeRootDir(data.rootDir)); - const componentMaps = trackData.map((data) => { + const componentMaps: ComponentMap[] = []; + for (const data of trackData) { const { files, componentName, defaultScope, mainFile, config } = data; if (path.isAbsolute(data.rootDir)) throw new BitError(`path is absolute, got ${data.rootDir}`); const rootDir = normalizeRootDir(data.rootDir); @@ -870,35 +872,9 @@ export async function addMultipleFromResolvedTrackData( const existingConfig = isWorkspaceRoot ? bitMap.getComponentIfExist(componentId, { ignoreVersion: true })?.config : undefined; - // files of components nested inside the workspace root belong to them, not to the root - whether - // tracked already or by this same call. the rule addOneComponent() and the rescan apply, so the - // map stays loadable: a root main-file inside a nested component fails validation before the map - // is written, not on the next load. - const nestedRootDirs = isWorkspaceRoot - ? uniq([...bitMap.getNestedRootDirs(rootDir), ...batchRootDirs.filter((dir) => dir !== WORKSPACE_ROOT_DIR)]) - : []; - // and the config files "bit ws-config write" generates are not source, the rule the rescan applies - // (see getFilesByDir), so one of them cannot be the main file the next load drops. - const ownedFiles = files.filter((file) => { - const relativePath = pathNormalizeToLinux(file); - if (nestedRootDirs.some((nestedRootDir) => relativePath.startsWith(`${nestedRootDir}/`))) return false; - return trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(relativePath); - }); - - // the ignore rules are written against the workspace root, so a file is matched by its - // workspace-relative path, then mapped back to the component-relative one the map stores. - const workspaceRelative = ownedFiles.map((file) => path.posix.join(rootDir, pathNormalizeToLinux(file))); - const filtered: string[] = gitIgnore - .filter(workspaceRelative) - .map((file) => (isWorkspaceRoot ? file : path.posix.relative(rootDir, file))); - if (!filtered.length) { - throw new NoFiles(files); - } - - const componentFiles = filtered.map((match: PathOsBased) => { - return { relativePath: pathNormalizeToLinux(match), name: path.basename(match) }; - }); - + const componentFiles = isWorkspaceRoot + ? await scanWorkspaceRootFiles(workspace, gitIgnore, batchRootDirs) + : filterResolvedFiles(rootDir, files, gitIgnore, trackAllFiles); const componentMap = bitMap.addComponent({ componentId, files: componentFiles, @@ -907,11 +883,51 @@ export async function addMultipleFromResolvedTrackData( mainFile, rootDir, }); - return componentMap; - }); + componentMaps.push(componentMap); + } const allIds = componentMaps.map((c) => c.id); await linkToNodeModulesByIds(workspace, allIds); return allIds; } + +/** + * the workspace-root's file-set is derived by scanning, the way every load derives it, rather than + * taken from the caller: the scan is what knows the nested components (tracked already, or by the same + * call), the ignore files below the root and bit's own exclusions. a main file the scan leaves out + * fails the map validation before the map is written, not on the next load. + */ +async function scanWorkspaceRootFiles( + workspace: Workspace, + gitIgnore: any, + batchRootDirs: PathLinuxRelative[] +): Promise { + const nestedRootDirs = uniq([ + ...workspace.consumer.bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR), + ...batchRootDirs.filter((dir) => dir !== WORKSPACE_ROOT_DIR), + ]); + const { trackAllFiles } = workspace.consumer.config; + return getFilesByDir(WORKSPACE_ROOT_DIR, workspace.path, gitIgnore, nestedRootDirs, trackAllFiles); +} + +/** + * the files the caller resolved for a component, minus the config files "bit ws-config write" + * generates (the rule the rescan applies, see getFilesByDir) and the ignored ones. the ignore rules + * are written against the workspace root, so a file is matched by its workspace-relative path, then + * mapped back to the component-relative one the map stores. + */ +function filterResolvedFiles( + rootDir: PathLinuxRelative, + files: string[], + gitIgnore: any, + trackAllFiles?: boolean +): ComponentMapFile[] { + const notGenerated = files + .map(pathNormalizeToLinux) + .filter((file) => trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(file)); + const workspaceRelative = notGenerated.map((file) => path.posix.join(rootDir, file)); + const filtered: string[] = gitIgnore.filter(workspaceRelative).map((file) => path.posix.relative(rootDir, file)); + if (!filtered.length) throw new NoFiles(files); + return filtered.map((relativePath) => ({ relativePath, name: path.basename(relativePath), test: false })); +} From d002b9a5aaff0145e51deedbaa500f114af35a7e Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:15:43 -0400 Subject: [PATCH 022/102] fix(tracker): scan dotfiles at add time for every component, as the rescan does Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 11 +++++++++++ scopes/component/tracker/add-components.ts | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 7acd87b07eef..37ab176a79ae 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -190,6 +190,17 @@ describe('add command on Harmony', function () { expect(output).to.have.string('.bitmap'); }); }); + describe('adding a component that has dotfiles', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.fs.outputFile('comp1/.npmrc', 'registry=https://example.com\n'); + }); + it('should list them at add time, as the rescan tracks them', () => { + const output = helper.command.addComponent('comp1', { i: 'comp1' }); + expect(output).to.have.string('.npmrc'); + }); + }); describe('adding a nested component that holds the main file of the workspace root', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 418cd36f078f..1e3d701a45be 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -564,10 +564,10 @@ you can add the directory these files are located at and it'll change the root d const matches = await glob(pathNormalizeToLinux(path.join(relativeComponentPath, '**')), { cwd: this.consumer.getPath(), nodir: true, - // the workspace root is full of dotfiles that belong to it (.gitignore, .github/**). without - // this, "bit add ." records an incomplete file-set that only the next rescan corrects, since - // getFilesByDir() scans with dot: true. - dot: relativeComponentPath === WORKSPACE_ROOT_DIR, + // dotfiles are component files like any other (the workspace root is full of them: .gitignore, + // .github/**). the rescan, getFilesByDir(), scans with dot: true, and the add-time file-set has to + // agree with it rather than be corrected by the next command. + dot: true, // the same exclusions the rescan applies, see getFilesByDir(). ignore: getScanIgnorePatterns(relativeComponentPath, nestedRootDirs), }); From 3054c16d990b5a6fc87e29ed04f5394ae50aa25a Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:25:44 -0400 Subject: [PATCH 023/102] fix(bit-map): drop lane state from the versioned map, honor nested ignore files that are ignored themselves Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 4 ++++ components/legacy/bit-map/bit-map.ts | 5 ++++- components/legacy/bit-map/component-map.spec.ts | 5 ++++- components/legacy/bit-map/component-map.ts | 11 +++++++++-- e2e/harmony/add-harmony.e2e.ts | 10 ++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index fb9c25d623e6..64e7c4fb167f 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -137,6 +137,7 @@ describe('BitMap', function () { describe('normalizeBitmapContentForVersioning', () => { const rawBitmap = JSON.stringify( { + _bit_lane: { id: { name: 'dev', scope: 'my-scope' }, exported: false }, comp1: { name: 'comp1', scope: 'my-scope', @@ -163,6 +164,9 @@ describe('BitMap', function () { it('should drop the config, which a snap moves into the version and removes from the map', () => { expect(parsed.comp1).to.not.have.property('config'); }); + it('should drop the lane, workspace state that export and lane switch change', () => { + expect(normalized).to.not.have.string('_bit_lane'); + }); it('should keep the scope, so cross-scope components survive a restore', () => { expect(parsed.comp1.scope).to.equal('my-scope'); }); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index d2dcb19ee22d..919265fdafd4 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1099,9 +1099,12 @@ type OutputFileParams = { export function normalizeBitmapContentForVersioning(rawContent: string): string { const parsed = json.parse(rawContent, undefined, true) as Record | undefined; if (!parsed) return rawContent; + // the lane the workspace is on, and whether it was exported: workspace state that export and + // lane switch change, not part of the map of components + delete parsed[LANE_KEY]; Object.keys(parsed).forEach((key) => { const entry = parsed[key]; - if (key === SCHEMA_FIELD || key === LANE_KEY || !entry || typeof entry !== 'object') return; + if (key === SCHEMA_FIELD || !entry || typeof entry !== 'object') return; if (entry.version !== undefined) entry.version = ''; delete entry.config; }); diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index ba869a5c216a..b97e8db2d825 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -66,7 +66,8 @@ describe('getFilesByDir', function () { workspacePath = await createWorkspace('bit-workspace-root-scan-', { 'README.md': '', '.bitmap': '', - '.gitignore': 'docs/*.txt\n', + // the root's rules ignore every nested .bitignore file - a nested one still applies, as in git + '.gitignore': 'docs/*.txt\n**/.bitignore\n', 'workspace.jsonc': '', '.github/ci.yml': '', // a git worktree or submodule: .git is a pointer file, not a directory @@ -84,6 +85,8 @@ describe('getFilesByDir', function () { // a vendored repository and a nested bit workspace that no component claims 'vendor/lib/.git/HEAD': '', 'vendor/lib/index.js': '', + 'vendor/lib/.bitignore': 'generated/\n', + 'vendor/lib/generated/x.js': '', 'nested/.bit/objects/bb': '', 'nested/.bit.map.json': '', 'nested/.bitmap': '', diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 6ee490f8c068..3aa7b2c6af43 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -122,7 +122,7 @@ export async function filterByIgnoreFiles( ): Promise { const filteredByRoot: PathLinux[] = gitIgnore.filter(relativePaths); if (dir !== WORKSPACE_ROOT_DIR) return filteredByRoot; - const nestedPatterns = await getNestedIgnorePatterns(consumerPath, filteredByRoot); + const nestedPatterns = await getNestedIgnorePatterns(consumerPath, gitIgnore, relativePaths); if (!nestedPatterns.length) return filteredByRoot; const filteredByUserRules: PathLinux[] = ignore().add(gitIgnore).add(nestedPatterns).filter(relativePaths); return ignore() @@ -130,13 +130,20 @@ export async function filterByIgnoreFiles( .filter(filteredByUserRules); } -async function getNestedIgnorePatterns(consumerPath: string, relativePaths: PathLinux[]): Promise { +async function getNestedIgnorePatterns( + consumerPath: string, + gitIgnore: any, + relativePaths: PathLinux[] +): Promise { const ignoreFileByDir = new Map(); relativePaths.forEach((relativePath) => { const name = path.basename(relativePath); if (name !== GIT_IGNORE && name !== BIT_IGNORE) return; const fileDir = path.dirname(relativePath); if (fileDir === '.') return; // the root's own ignore file is in the workspace ignore list already + // an ignore file applies even when it is ignored itself, as long as its directory is scanned: + // git does not descend into an ignored directory + if (gitIgnore.ignores(`${fileDir}/`)) return; if (name === BIT_IGNORE || !ignoreFileByDir.has(fileDir)) ignoreFileByDir.set(fileDir, name); }); if (!ignoreFileByDir.size) return []; diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 37ab176a79ae..f2c37698ebc3 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -261,6 +261,16 @@ describe('add command on Harmony', function () { // the exported .bitmap lists comp1. the restored workspace must not inherit that entry. expect(helper.bitMap.read()).to.not.have.property('comp1'); }); + it('should not overwrite a workspace.jsonc the user changed since, on a second import without --override', () => { + // the workspace is not fresh anymore: its root is tracked, and its config is the user's + helper.workspaceJsonc.addKeyValToWorkspace('name', 'renamed'); + try { + helper.command.importComponentWithoutInstall('ws-root', '--path .'); + } catch { + // refused, which is fine too + } + expect(helper.workspaceJsonc.read()['teambit.workspace/workspace'].name).to.equal('renamed'); + }); }); describe('importing it onto the root of a fresh workspace that has its own files', () => { before(() => { From 0cbbcdb8ec95fb5dcad5edf85a72c1b2add971b7 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:35:48 -0400 Subject: [PATCH 024/102] fix(tracker): keep the tracked id on bulk re-track, apply the own ignore file to resolved files Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/component-map.ts | 37 ++++++++++++---------- components/legacy/bit-map/index.ts | 1 + e2e/harmony/add-harmony.e2e.ts | 16 +++------- scopes/component/tracker/add-components.ts | 32 ++++++++++++------- 4 files changed, 47 insertions(+), 39 deletions(-) diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 3aa7b2c6af43..e4e2e95c4ca2 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -130,6 +130,26 @@ export async function filterByIgnoreFiles( .filter(filteredByUserRules); } +/** + * the component's own ignore file (.bitignore, else .gitignore, at its root), applied to its files. + * resolved against the workspace, not the process cwd: `dir` is workspace-relative, so running bit + * from a sub-directory would otherwise look in the wrong place - and getBitIgnoreFile() does not + * swallow ENOENT. not for the workspace root: its own file is the workspace's, part of `gitIgnore` + * and evaluated together with the nested ones - applied again on its own it would undo their negations. + */ +export async function filterByOwnIgnoreFile( + dir: PathLinux, + consumerPath: string, + relativePaths: PathLinux[] +): Promise { + if (dir === WORKSPACE_ROOT_DIR) return relativePaths; + const ignoreFileDir = path.join(consumerPath, dir); + const ownIgnoreFile = relativePaths.includes(BIT_IGNORE) + ? await getBitIgnoreFile(ignoreFileDir) + : await getGitIgnoreFile(ignoreFileDir); + return ownIgnoreFile.length ? ignore().add(ownIgnoreFile).filter(relativePaths) : relativePaths; +} + async function getNestedIgnorePatterns( consumerPath: string, gitIgnore: any, @@ -546,22 +566,7 @@ export async function getFilesByDir( const filteredByIgnoredFromRoot = trackAllFiles ? relativePathsLinux : relativePathsLinux.filter((match) => !IGNORE_ROOT_ONLY_LIST.includes(match)); - // resolve against the workspace, not the process cwd. `dir` is workspace-relative, so running bit - // from a sub-directory would otherwise look for the ignore file in the wrong place - and - // getBitIgnoreFile() does not swallow ENOENT, so it throws rather than falling back. - const ignoreFileDir = path.join(consumerPath, dir); - // the component's own ignore file, applied to its files. for the workspace root that file is the - // workspace's, already part of `gitIgnore` and evaluated above together with the nested ones - - // applied again on its own it would undo their negations. - const ownIgnoreFile = - dir === WORKSPACE_ROOT_DIR - ? [] - : filteredByIgnoredFromRoot.includes(BIT_IGNORE) - ? await getBitIgnoreFile(ignoreFileDir) - : await getGitIgnoreFile(ignoreFileDir); - const filteredByBitIgnore = ownIgnoreFile.length - ? ignore().add(ownIgnoreFile).filter(filteredByIgnoredFromRoot) - : filteredByIgnoredFromRoot; + const filteredByBitIgnore = await filterByOwnIgnoreFile(dir, consumerPath, filteredByIgnoredFromRoot); if (!filteredByBitIgnore.length) throw new IgnoredDirectory(dir); return filteredByBitIgnore.map((relativePath) => ({ relativePath, diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index b321c91e14a5..498b34ca14db 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -15,6 +15,7 @@ export { ComponentMap, Config, filterByIgnoreFiles, + filterByOwnIgnoreFile, getFilesByDir, getIgnoreListHarmony, getScanIgnorePatterns, diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index f2c37698ebc3..0530e7ba7960 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -82,8 +82,13 @@ describe('add command on Harmony', function () { // a direct child ("bit add . comp1") is dropped from the batch as a wildcard expansion of ".", // a pre-existing rule. a deeper one is added alongside the root. helper.fs.outputFile('packages/comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.fs.outputFile('packages/comp1/.npmrc', 'registry=https://example.com\n'); addedComponents = JSON.parse(helper.command.runCmd('bit add . packages/comp1 --json')).addedComponents; }); + it('should list the dotfiles of a nested component at add time, as the rescan tracks them', () => { + const nested = addedComponents.find((added) => added.id.endsWith('comp1')); + expect(nested?.files.some((file) => file.endsWith('.npmrc'))).to.be.true; + }); it('should leave the nested component files out of the root, already at add time', () => { // the nested component is not in .bitmap yet when the root is scanned, so the batch itself has // to provide the exclusion. otherwise the two own the same files until the next rescan. @@ -190,17 +195,6 @@ describe('add command on Harmony', function () { expect(output).to.have.string('.bitmap'); }); }); - describe('adding a component that has dotfiles', () => { - before(() => { - helper.scopeHelper.reInitWorkspace(); - helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); - helper.fs.outputFile('comp1/.npmrc', 'registry=https://example.com\n'); - }); - it('should list them at add time, as the rescan tracks them', () => { - const output = helper.command.addComponent('comp1', { i: 'comp1' }); - expect(output).to.have.string('.npmrc'); - }); - }); describe('adding a nested component that holds the main file of the workspace root', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 1e3d701a45be..b5f46efad0fc 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -20,6 +20,7 @@ import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { ComponentMap, filterByIgnoreFiles, + filterByOwnIgnoreFile, getFilesByDir, getIgnoreListHarmony, getScanIgnorePatterns, @@ -869,16 +870,18 @@ export async function addMultipleFromResolvedTrackData( // re-tracking the workspace root with the same id is a no-op, not a second owner throwForExistingParentDir(bitMap, rootDir, componentId); const isWorkspaceRoot = rootDir === WORKSPACE_ROOT_DIR; - const existingConfig = isWorkspaceRoot - ? bitMap.getComponentIfExist(componentId, { ignoreVersion: true })?.config - : undefined; + // re-tracking keeps the entry's id, version included, the way "bit add" does: the id built from + // the name alone would replace it and make a snapped component look newly tracked. + const existingEntry = bitMap.getComponentIfExist(componentId, { ignoreVersion: true }); + const idToTrack = existingEntry?.id ?? componentId; + const existingConfig = isWorkspaceRoot ? existingEntry?.config : undefined; const componentFiles = isWorkspaceRoot ? await scanWorkspaceRootFiles(workspace, gitIgnore, batchRootDirs) - : filterResolvedFiles(rootDir, files, gitIgnore, trackAllFiles); + : await filterResolvedFiles(rootDir, workspace.path, files, gitIgnore, trackAllFiles); const componentMap = bitMap.addComponent({ - componentId, + componentId: idToTrack, files: componentFiles, - defaultScope, + defaultScope: idToTrack.hasScope() ? undefined : defaultScope, config: isWorkspaceRoot ? configForWorkspaceRoot(existingConfig, config) : config, mainFile, rootDir, @@ -913,21 +916,26 @@ async function scanWorkspaceRootFiles( /** * the files the caller resolved for a component, minus the config files "bit ws-config write" - * generates (the rule the rescan applies, see getFilesByDir) and the ignored ones. the ignore rules - * are written against the workspace root, so a file is matched by its workspace-relative path, then - * mapped back to the component-relative one the map stores. + * generates and the ignored ones - the rules the rescan applies, see getFilesByDir. the workspace + * ignore rules are written against the workspace root, so a file is matched by its workspace-relative + * path and mapped back to the component-relative one the map stores; the component's own ignore file + * is applied to the latter. */ -function filterResolvedFiles( +async function filterResolvedFiles( rootDir: PathLinuxRelative, + consumerPath: string, files: string[], gitIgnore: any, trackAllFiles?: boolean -): ComponentMapFile[] { +): Promise { const notGenerated = files .map(pathNormalizeToLinux) .filter((file) => trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(file)); const workspaceRelative = notGenerated.map((file) => path.posix.join(rootDir, file)); - const filtered: string[] = gitIgnore.filter(workspaceRelative).map((file) => path.posix.relative(rootDir, file)); + const filteredByWorkspaceRules: string[] = gitIgnore + .filter(workspaceRelative) + .map((file) => path.posix.relative(rootDir, file)); + const filtered = await filterByOwnIgnoreFile(rootDir, consumerPath, filteredByWorkspaceRules); if (!filtered.length) throw new NoFiles(files); return filtered.map((relativePath) => ({ relativePath, name: path.basename(relativePath), test: false })); } From 8ab2f697c09139be3a9cda9441e3c689fc53e7c1 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:43:25 -0400 Subject: [PATCH 025/102] fix(component-writer): accept "--path ." only for a component its versioned map lists as the root owner Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 23 ++++++++++++++++++- components/legacy/bit-map/bit-map.ts | 17 ++++++++++++++ components/legacy/bit-map/index.ts | 1 + .../component-writer.main.runtime.ts | 13 +++++------ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 64e7c4fb167f..09f848004e84 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -2,7 +2,12 @@ import { expect } from 'chai'; import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; import { logger } from '@teambit/legacy.logger'; -import { BitMap, fileContentsForVersioning, normalizeBitmapContentForVersioning } from './bit-map'; +import { + BitMap, + fileContentsForVersioning, + isWorkspaceMapOwnedBy, + normalizeBitmapContentForVersioning, +} from './bit-map'; import { WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -226,6 +231,22 @@ describe('BitMap', function () { expect(fileContentsForVersioning(nestedMap, '.bitmap', rawBitmap)).to.equal(rawBitmap); }); }); + describe('isWorkspaceMapOwnedBy', () => { + const rawMap = JSON.stringify({ + '$schema-version': '17.0.0', + 'ws-root': { scope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }, + comp1: { scope: 'my-scope', version: '', mainFile: 'index.ts', rootDir: 'packages/comp1' }, + }); + const id = (name: string) => ComponentID.fromObject({ name }, 'my-scope'); + it('should recognize the component the map lists as the owner of the workspace root', () => { + expect(isWorkspaceMapOwnedBy(rawMap, id('ws-root'))).to.be.true; + }); + it('should reject a component the map lists elsewhere, or not at all, and a map it cannot parse', () => { + expect(isWorkspaceMapOwnedBy(rawMap, id('comp1'))).to.be.false; + expect(isWorkspaceMapOwnedBy(rawMap, id('other'))).to.be.false; + expect(isWorkspaceMapOwnedBy('not a map', id('ws-root'))).to.be.false; + }); + }); describe('loading a workspace that has no .bitmap yet', () => { it('should keep the ignore options, they apply to the scans of the components added next', async () => { const bitMap = await BitMap.load(__dirname, '', ['*.bak'], true); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 919265fdafd4..e9924d2a6a81 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1096,6 +1096,23 @@ type OutputFileParams = { * collapse onto the default scope, and same-named components from different scopes would overwrite * each other. */ +/** + * whether a versioned map is the workspace map of the given component: it lists the component itself + * as the owner of the workspace root. this is what tells a workspace-root component from an ordinary + * one that happens to carry a file of that name, when it is written to ".". + */ +export function isWorkspaceMapOwnedBy(rawContent: Buffer | string, id: ComponentID): boolean { + let parsed: Record | undefined; + try { + parsed = json.parse(rawContent.toString(), undefined, true) as Record | undefined; + } catch { + return false; + } + const entry = parsed?.[id.fullName] ?? parsed?.[id.toStringWithoutVersion()]; + if (!entry || typeof entry !== 'object' || entry.rootDir !== WORKSPACE_ROOT_DIR) return false; + return !entry.scope || !id.scope || entry.scope === id.scope; +} + export function normalizeBitmapContentForVersioning(rawContent: string): string { const parsed = json.parse(rawContent, undefined, true) as Record | undefined; if (!parsed) return rawContent; diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 498b34ca14db..a13838dcf567 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -7,6 +7,7 @@ export { LANE_KEY, normalizeBitmapContentForVersioning, fileContentsForVersioning, + isWorkspaceMapOwnedBy, } from './bit-map'; export { MissingBitMapComponent, MissingMainFile, InvalidBitMap } from './exceptions'; export { diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 9a50e12663b7..efbef902673b 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -19,7 +19,7 @@ import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { isDir, isDirEmptySync, pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ComponentMap } from '@teambit/legacy.bit-map'; -import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile, isWorkspaceMapOwnedBy, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { COMPONENT_CONFIG_FILE_NAME, WORKSPACE_JSONC } from '@teambit/legacy.constants'; import { DataToPersist } from '@teambit/component.sources'; import type { ConfigMergerMain, WorkspaceConfigUpdateResult } from '@teambit/config-merger'; @@ -338,14 +338,13 @@ to move all component files to a different directory, run bit remove and then bi /** * only a workspace-root component may be written to ".". any other component written there would own * every unclaimed file in the workspace from the next scan on, and drop out of install and link. a - * workspace-root component is told by the workspace map it versions (see isWorkspaceMapFile): no - * other component carries one at its root, its scan never hands it one. + * workspace-root component is told by the workspace map it versions, which lists the component + * itself as the owner of the root (see isWorkspaceMapOwnedBy) - a file of that name alone is not + * enough, a component snapped before maps were excluded from component scans may carry one. */ private throwForNonWorkspaceRootComponent(component: ConsumerComponent) { - const versionsTheWorkspaceMap = component.files.some((file) => - isWorkspaceMapFile(pathNormalizeToLinux(file.relative)) - ); - if (versionsTheWorkspaceMap) return; + const mapFile = component.files.find((file) => isWorkspaceMapFile(pathNormalizeToLinux(file.relative))); + if (mapFile && isWorkspaceMapOwnedBy(mapFile.contents, component.id)) return; throw new BitError( `unable to import "${component.id.toString()}" to the workspace root, it is not a workspace-root component. use --path to write it to a directory` ); From 872d041904d7713224415ae3fd56359e3c722f04 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:52:37 -0400 Subject: [PATCH 026/102] fix(tracker): guard the workspace-root main file on bulk tracking, keep the root out of dir relocation Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 5 ++- e2e/harmony/add-harmony.e2e.ts | 3 ++ .../component-writer.main.runtime.ts | 6 +++- scopes/component/tracker/add-components.ts | 36 +++++++++++++------ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 09f848004e84..1fddcd335079 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -197,7 +197,10 @@ describe('BitMap', function () { const bitMap = await getBitmapInstance(); bitMap.addComponent(componentParams); const addAnother = () => - bitMap.addComponent({ ...componentParams, componentId: ComponentID.fromObject({ name: 'comp2' }, 'my-scope') }); + bitMap.addComponent({ + ...componentParams, + componentId: ComponentID.fromObject({ name: 'comp2' }, 'my-scope'), + }); expect(addAnother).to.throw('already used by another component'); // and the rejected entry is not left behind in the map expect(bitMap.components).to.have.lengthOf(1); diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 0530e7ba7960..bcc9734a5537 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -251,6 +251,9 @@ describe('add command on Harmony', function () { it('should record "." as its rootDir', () => { expect(helper.bitMap.read()['ws-root'].rootDir).to.equal('.'); }); + it('should keep the empty env, which its version carries', () => { + expect(helper.env.getComponentEnv('ws-root')).to.equal('teambit.harmony/empty-env'); + }); it('should leave the live .bitmap alone rather than overwrite it with the exported one', () => { // the exported .bitmap lists comp1. the restored workspace must not inherit that entry. expect(helper.bitMap.read()).to.not.have.property('comp1'); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index efbef902673b..adb983f43fea 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -284,7 +284,8 @@ export class ComponentWriterMain { const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. - if (this.consumer && !opts.writeToEmptyDir) { + // the workspace root is never relocated (see relocateOccupiedDirs), so its own check runs either way. + if (this.consumer && (!opts.writeToEmptyDir || componentRootDir === WORKSPACE_ROOT_DIR)) { this.throwErrorWhenDirectoryNotEmpty(component, componentRootDir, existingComponentMap, opts); } return { @@ -443,6 +444,9 @@ either use --path to specify a different directory or modify "defaultDirectory" componentWriterInstances.forEach((componentWriter) => { const currentDir = componentWriter.writeToPath; const componentMap = componentWriter.existingComponentMap; + // the workspace root is a target only for a workspace-root component, and "._1" is not the + // workspace root. its conflicts are handled by throwForOccupiedWorkspaceRoot. + if (currentDir === WORKSPACE_ROOT_DIR) return; if (this.shouldSkipDirConflictCheck(currentDir, componentMap, opts)) return; const unavailableReason = this.getDirUnavailableReason(currentDir, componentMap); if (!unavailableReason) return; diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index b5f46efad0fc..043a8bea2cd0 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -269,16 +269,8 @@ export default class AddComponents { const ownedByWorkspaceRoot = Boolean( workspaceRootMap && existingIdOfFile?.isEqualWithoutVersion(workspaceRootMap.id) ); - // the ownership lookup above is case-insensitive, so this comparison is too - if ( - workspaceRootMap && - idOfFileIsDifferent && - ownedByWorkspaceRoot && - file.relativePath.toLowerCase() === workspaceRootMap.mainFile.toLowerCase() - ) { - throw new BitError( - `unable to add "${file.relativePath}" to "${parsedBitId.toString()}", it is the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` - ); + if (workspaceRootMap && idOfFileIsDifferent && ownedByWorkspaceRoot) { + throwForTakingWorkspaceRootMainFile(workspaceRootMap, parsedBitId, file.relativePath); } if (idOfFileIsDifferent && !ownedByWorkspaceRoot) { // not imported component file but exists in bitmap @@ -878,6 +870,14 @@ export async function addMultipleFromResolvedTrackData( const componentFiles = isWorkspaceRoot ? await scanWorkspaceRootFiles(workspace, gitIgnore, batchRootDirs) : await filterResolvedFiles(rootDir, workspace.path, files, gitIgnore, trackAllFiles); + // a nested component may take any file from a tracked workspace root but its main file, the + // rule "bit add" applies. a root tracked later in the same call scans around this component. + const workspaceRootMap = bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); + if (!isWorkspaceRoot && workspaceRootMap) { + componentFiles.forEach((file) => + throwForTakingWorkspaceRootMainFile(workspaceRootMap, idToTrack, path.posix.join(rootDir, file.relativePath)) + ); + } const componentMap = bitMap.addComponent({ componentId: idToTrack, files: componentFiles, @@ -895,6 +895,22 @@ export async function addMultipleFromResolvedTrackData( return allIds; } +/** + * the workspace-root component owns every file no other component claims, so it gives a file away to + * a more specific component - all but its main file: without it, it fails to load from the next scan on. + */ +function throwForTakingWorkspaceRootMainFile( + workspaceRootMap: ComponentMap, + componentId: ComponentID, + relativePath: PathLinux +) { + // the ownership lookups are case-insensitive, so this comparison is too + if (relativePath.toLowerCase() !== workspaceRootMap.mainFile.toLowerCase()) return; + throw new BitError( + `unable to add "${relativePath}" to "${componentId.toString()}", it is the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` + ); +} + /** * the workspace-root's file-set is derived by scanning, the way every load derives it, rather than * taken from the caller: the scan is what knows the nested components (tracked already, or by the same From ad5d1a2c983ed7a65f299a0d4c0914d2e1abd65c Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 11:59:48 -0400 Subject: [PATCH 027/102] fix(workspace-root): refuse symlinked ancestors on a root import, watch manifests when trackAllFiles is set Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 11 +++++++ .../component-writer.main.runtime.ts | 29 +++++++++++++++++-- scopes/workspace/watcher/watcher.ts | 14 +++++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index bcc9734a5537..b9b2fea9536e 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -213,6 +213,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); + helper.fs.outputFile('docs/guide.md', '# guide\n'); helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); firstSnap = helper.command.getHead('ws-root'); @@ -300,6 +301,16 @@ describe('add command on Harmony', function () { expect(cmd).to.throw('use --override'); expect(target).to.not.be.a.path(); }); + it('should refuse a symlinked ancestor directory even with --override, rather than write through it', () => { + fs.unlinkSync(path.join(helper.scopes.localPath, 'README.md')); + const outside = path.join(helper.scopes.localPath, '..', `outside-${path.basename(helper.scopes.localPath)}`); + fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(helper.scopes.localPath, 'docs')); + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path . --override'); + expect(cmd).to.throw('symbolic link'); + expect(path.join(outside, 'guide.md')).to.not.be.a.path(); + fs.removeSync(outside); + }); }); describe('importing an ordinary component onto the root', () => { before(() => { diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index adb983f43fea..b011205aac3f 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -279,7 +279,10 @@ export class ComponentWriterMain { ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) || WORKSPACE_ROOT_DIR : this.consumer.composeRelativeComponentPath(component.id); - if (componentRootDir === WORKSPACE_ROOT_DIR) this.throwForNonWorkspaceRootComponent(component); + if (componentRootDir === WORKSPACE_ROOT_DIR) { + this.throwForNonWorkspaceRootComponent(component); + this.throwForSymlinkedAncestors(component); + } // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the @@ -336,6 +339,27 @@ to move all component files to a different directory, run bit remove and then bi return componentMap.rootDir === componentDirRelative; } + /** + * a workspace-root component's files land in the workspace tree itself, where a directory may be a + * symbolic link the user made. a write through it would land the file wherever the link points, so + * every existing ancestor of an incoming file has to be a real directory. this is about where the + * write goes, not what it replaces, so --override does not waive it. + */ + private throwForSymlinkedAncestors(component: ConsumerComponent) { + const ancestors = new Set(); + component.files.forEach((file) => { + const segments = pathNormalizeToLinux(file.relative).split('/').slice(0, -1); + segments.forEach((_, index) => ancestors.add(segments.slice(0, index + 1).join('/'))); + }); + ancestors.forEach((ancestor) => { + const stat = lstatIfExists(this.consumer.toAbsolutePath(ancestor)); + if (!stat?.isSymbolicLink()) return; + throw new BitError( + `unable to import "${component.id.toString()}" to the workspace root, "${ancestor}" is a symbolic link and the import would write through it` + ); + }); + } + /** * only a workspace-root component may be written to ".". any other component written there would own * every unclaimed file in the workspace from the next scan on, and drop out of install and link. a @@ -377,10 +401,11 @@ to move all component files to a different directory, run bit remove and then bi // to write through, and so is a directory. only a regular file is compared with the incoming copy. const stat = lstatIfExists(absolutePath); if (!stat) return false; + if (!stat.isFile()) return true; // compared byte for byte through latin1, which maps each byte to one character. Buffer.equals is // avoided because its signature differs between @types/node majors, and an aspect build type-checks // this file with whichever version its capsule resolves. - return !stat.isFile() || fs.readFileSync(absolutePath, 'latin1') !== file.contents.toString('latin1'); + return fs.readFileSync(absolutePath, 'latin1') !== file.contents.toString('latin1'); }) .map((file) => pathNormalizeToLinux(file.relative)); if (!filesToOverwrite.length) return; diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index af34770c6352..ef760b6eae9b 100644 --- a/scopes/workspace/watcher/watcher.ts +++ b/scopes/workspace/watcher/watcher.ts @@ -168,9 +168,14 @@ export class Watcher { return this.workspace.consumer; } - private getParcelIgnorePatterns(): string[] { + /** + * the paths the watcher never reports, for either backend. package.json is bit's own output, unless + * the workspace tracks every file (trackAllFiles): then it is component source and its edits count. + */ + private getIgnorePatterns(): string[] { const relScopePath = pathNormalizeToLinux(relative(this.workspace.path, this.workspace.scope.path)); - return ['**/node_modules/**', '**/package.json', `**/${relScopePath}/**`]; + const manifests = this.consumer.config.trackAllFiles ? [] : ['**/package.json']; + return ['**/node_modules/**', ...manifests, `**/${relScopePath}/**`]; } /** @@ -180,7 +185,7 @@ export class Watcher { */ private getParcelWatcherOptions(): ParcelWatcherOptions { const options: ParcelWatcherOptions = { - ignore: this.getParcelIgnorePatterns(), + ignore: this.getIgnorePatterns(), }; // On macOS, prefer Watchman if available to avoid FSEvents stream limit @@ -925,8 +930,7 @@ export class Watcher { const chokidarOpts = await this.watcherMain.getChokidarWatchOptions(); // `chokidar` matchers have Bash-parity, so Windows-style backslashes are not supported as separators. // (windows-style backslashes are converted to forward slashes) - const relScopePath = pathNormalizeToLinux(relative(this.workspace.path, this.workspace.scope.path)); - chokidarOpts.ignored = ['**/node_modules/**', '**/package.json', `**/${relScopePath}/**`]; + chokidarOpts.ignored = this.getIgnorePatterns(); this.chokidarWatcher = chokidar.watch(this.workspace.path, chokidarOpts); if (this.verbose) { logger.console( From 8a4a106aebb830fd5d5b158b8504613310ccaa13 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 12:11:36 -0400 Subject: [PATCH 028/102] fix(bit-map): do not follow symlinks in the root scan, resolve the map entry scope from defaultScope Co-Authored-By: Claude Fable 5.1 --- components/legacy/bit-map/bit-map.spec.ts | 13 ++++++++++++- components/legacy/bit-map/bit-map.ts | 5 ++++- components/legacy/bit-map/component-map.spec.ts | 7 ++++++- components/legacy/bit-map/component-map.ts | 7 ++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 1fddcd335079..42e28bad8bd8 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -240,10 +240,21 @@ describe('BitMap', function () { 'ws-root': { scope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }, comp1: { scope: 'my-scope', version: '', mainFile: 'index.ts', rootDir: 'packages/comp1' }, }); - const id = (name: string) => ComponentID.fromObject({ name }, 'my-scope'); + const id = (name: string, scope = 'my-scope') => ComponentID.fromObject({ name }, scope); it('should recognize the component the map lists as the owner of the workspace root', () => { expect(isWorkspaceMapOwnedBy(rawMap, id('ws-root'))).to.be.true; }); + it('should resolve the scope of an entry snapped before its export from its defaultScope', () => { + const entry = { scope: '', defaultScope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }; + const rawMapBeforeExport = JSON.stringify({ 'ws-root': entry }); + expect(isWorkspaceMapOwnedBy(rawMapBeforeExport, id('ws-root'))).to.be.true; + expect(isWorkspaceMapOwnedBy(rawMapBeforeExport, id('ws-root', 'other-scope'))).to.be.false; + const rawMapWithoutScope = JSON.stringify({ 'ws-root': { ...entry, defaultScope: undefined } }); + expect(isWorkspaceMapOwnedBy(rawMapWithoutScope, id('ws-root'))).to.be.false; + }); + it('should reject the same name from another scope', () => { + expect(isWorkspaceMapOwnedBy(rawMap, id('ws-root', 'other-scope'))).to.be.false; + }); it('should reject a component the map lists elsewhere, or not at all, and a map it cannot parse', () => { expect(isWorkspaceMapOwnedBy(rawMap, id('comp1'))).to.be.false; expect(isWorkspaceMapOwnedBy(rawMap, id('other'))).to.be.false; diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index e9924d2a6a81..9c81ba3d543a 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1110,7 +1110,10 @@ export function isWorkspaceMapOwnedBy(rawContent: Buffer | string, id: Component } const entry = parsed?.[id.fullName] ?? parsed?.[id.toStringWithoutVersion()]; if (!entry || typeof entry !== 'object' || entry.rootDir !== WORKSPACE_ROOT_DIR) return false; - return !entry.scope || !id.scope || entry.scope === id.scope; + // an entry snapped before its export carries its scope as defaultScope. either way it has to be the + // scope the component comes from - an empty one is not a wildcard. + const entryScope = entry.scope || entry.defaultScope; + return Boolean(entryScope) && entryScope === id.scope; } export function normalizeBitmapContentForVersioning(rawContent: string): string { diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index b97e8db2d825..48606e86f086 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -62,6 +62,7 @@ describe('getFilesByDir', function () { describe('scanning the workspace root', () => { let workspacePath: string; + let outsidePath: string; before(async () => { workspacePath = await createWorkspace('bit-workspace-root-scan-', { 'README.md': '', @@ -105,8 +106,12 @@ describe('getFilesByDir', function () { // "tmp/" ignores directories only. this is a file 'docs/tmp': '', }); + // a symbolic link at the root, to a directory outside the workspace. what it points to is not + // the workspace's source, so its files must never be tracked + outsidePath = await createWorkspace('bit-outside-the-workspace-', { 'secret.txt': 'SECRET' }); + await fs.symlink(outsidePath, path.join(workspacePath, 'linked')); }); - after(() => fs.remove(workspacePath)); + after(() => Promise.all([fs.remove(workspacePath), fs.remove(outsidePath)])); // bit runs with the workspace as its cwd. globby stats ignore patterns relative to the process // cwd, so the `.git` pointer file is only exercised from inside the workspace. diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index e4e2e95c4ca2..150e2cc472b8 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -183,7 +183,8 @@ function rebaseIgnorePattern(pattern: string, dir: PathLinux): string { const anchored = body.slice(0, -1).includes('/'); // a trailing slash means "directories only". the join drops it, so it is put back. const dirOnly = body.endsWith('/') ? '/' : ''; - const rebased = (anchored ? pathJoinLinux(dir, body.replace(/^\//, '')) : pathJoinLinux(dir, '**', body)) + dirOnly; + const base = anchored ? pathJoinLinux(dir, body.replace(/^\//, '')) : pathJoinLinux(dir, '**', body); + const rebased = base + dirOnly; return negated ? `!${rebased}` : rebased; } @@ -552,6 +553,10 @@ export async function getFilesByDir( dot: true, onlyFiles: true, ignore: getScanIgnorePatterns(dir, excludeDirs), + // the workspace root is the one scan that spans the whole tree, where a symbolic link the user made + // may lead anywhere. what it points to is not the workspace's source, so the link is not followed - + // the add-time scan does not follow links either. + followSymbolicLinks: dir !== WORKSPACE_ROOT_DIR, // every pattern here is an explicit glob. with expansion on, globby stats each ignore pattern // (relative to the process cwd, not to `cwd`) to decide whether to expand it, and `.git/**` // throws ENOTDIR wherever `.git` is a file - every git worktree. From 472549e8a8e445240ccd34fcca0ec44f5ce83df5 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 12:19:41 -0400 Subject: [PATCH 029/102] fix(tracker): apply the component's own ignore file at add time, as the rescan does Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/bit-ignore.e2e.ts | 5 +++++ scopes/component/tracker/add-components.ts | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/e2e/harmony/bit-ignore.e2e.ts b/e2e/harmony/bit-ignore.e2e.ts index 2639a01db535..600d740bb3ed 100644 --- a/e2e/harmony/bit-ignore.e2e.ts +++ b/e2e/harmony/bit-ignore.e2e.ts @@ -24,6 +24,11 @@ describe('Bit Ignore functionality', function () { expect(files).to.not.include('hello.json'); expect(files).to.include('index.js'); }); + it('should respect it at add time as well, not only on the next rescan', () => { + const output = helper.command.addComponent('comp1', { i: 'comp1' }); + expect(output).to.have.string('index.js'); + expect(output).to.not.have.string('hello.json'); + }); }); describe('adding .bitignore in the root dir', () => { before(() => { diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 043a8bea2cd0..f83dac565846 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -579,8 +579,20 @@ you can add the directory these files are located at and it'll change the root d matches.map(pathNormalizeToLinux), this.consumer.config.trackAllFiles ); + // and by the component's own ignore file, the rule the rescan applies (see getFilesByDir) - it + // takes paths relative to the component, the matches are relative to the workspace. + const relativeToComponent = (match: PathLinux) => path.posix.relative(relativeComponentPath, match); + const keptByOwnIgnoreFile = new Set( + await filterByOwnIgnoreFile( + relativeComponentPath, + this.consumer.getPath(), + matchesNotIgnored.map(relativeToComponent) + ) + ); const filteredMatches = matchesNotIgnored.filter( - (match) => this.consumer.config.trackAllFiles || !generatedAtRoot.has(match) + (match) => + keptByOwnIgnoreFile.has(relativeToComponent(match)) && + (this.consumer.config.trackAllFiles || !generatedAtRoot.has(match)) ); if (!filteredMatches.length) { From c62e5eeec604d9e06cc26b71a4c7aa3d1a69a63c Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 12:30:26 -0400 Subject: [PATCH 030/102] fix(tracker): guard the workspace-root main file by directory, not by the files a nested component keeps Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 6 ++++++ scopes/component/tracker/add-components.ts | 22 ++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index b9b2fea9536e..b7d992d7aab0 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -205,6 +205,12 @@ describe('add command on Harmony', function () { const cmd = () => helper.command.addComponent('packages/comp1', { i: 'comp1' }); expect(cmd).to.throw('main file of the workspace-root component'); }); + it('should refuse even when the nested component ignores that file, its directory is what the root loses', () => { + helper.fs.outputFile('packages/comp1/.bitignore', 'index.js\n'); + helper.fs.outputFile('packages/comp1/other.js', ''); + const cmd = () => helper.command.addComponent('packages/comp1', { i: 'comp1' }); + expect(cmd).to.throw('main file of the workspace-root component'); + }); }); describe('writing the workspace-root component to the filesystem', () => { let firstSnap: string; diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index f83dac565846..001dab1f80e7 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -254,6 +254,9 @@ export default class AddComponents { // give away is its main file - without it, it fails to load from the next scan on. const workspaceRootMap = this.bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); const isWorkspaceRoot = component.trackDir === WORKSPACE_ROOT_DIR; + if (workspaceRootMap && !isWorkspaceRoot && !parsedBitId.isEqualWithoutVersion(workspaceRootMap.id)) { + throwForTakingWorkspaceRootMainFile(workspaceRootMap, parsedBitId, pathNormalizeToLinux(component.trackDir)); + } const componentFilesP = files.map(async (file: ComponentMapFile) => { // $FlowFixMe null is removed later on const filePath = path.join(consumerPath, file.relativePath); @@ -269,9 +272,6 @@ export default class AddComponents { const ownedByWorkspaceRoot = Boolean( workspaceRootMap && existingIdOfFile?.isEqualWithoutVersion(workspaceRootMap.id) ); - if (workspaceRootMap && idOfFileIsDifferent && ownedByWorkspaceRoot) { - throwForTakingWorkspaceRootMainFile(workspaceRootMap, parsedBitId, file.relativePath); - } if (idOfFileIsDifferent && !ownedByWorkspaceRoot) { // not imported component file but exists in bitmap // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! @@ -885,11 +885,7 @@ export async function addMultipleFromResolvedTrackData( // a nested component may take any file from a tracked workspace root but its main file, the // rule "bit add" applies. a root tracked later in the same call scans around this component. const workspaceRootMap = bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); - if (!isWorkspaceRoot && workspaceRootMap) { - componentFiles.forEach((file) => - throwForTakingWorkspaceRootMainFile(workspaceRootMap, idToTrack, path.posix.join(rootDir, file.relativePath)) - ); - } + if (!isWorkspaceRoot && workspaceRootMap) throwForTakingWorkspaceRootMainFile(workspaceRootMap, idToTrack, rootDir); const componentMap = bitMap.addComponent({ componentId: idToTrack, files: componentFiles, @@ -914,12 +910,14 @@ export async function addMultipleFromResolvedTrackData( function throwForTakingWorkspaceRootMainFile( workspaceRootMap: ComponentMap, componentId: ComponentID, - relativePath: PathLinux + rootDir: PathLinux ) { - // the ownership lookups are case-insensitive, so this comparison is too - if (relativePath.toLowerCase() !== workspaceRootMap.mainFile.toLowerCase()) return; + // checked on the directory rather than on the files the component keeps: the root's next scan + // subtracts the whole directory, whether or not the component itself tracks that file. the + // ownership lookups are case-insensitive, so this comparison is too. + if (!workspaceRootMap.mainFile.toLowerCase().startsWith(`${rootDir.toLowerCase()}/`)) return; throw new BitError( - `unable to add "${relativePath}" to "${componentId.toString()}", it is the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` + `unable to add "${rootDir}" as "${componentId.toString()}", it contains "${workspaceRootMap.mainFile}", the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` ); } From 176b92cd8638296ef602cc011824280f72af1387 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 12:38:06 -0400 Subject: [PATCH 031/102] fix(component-writer): refuse a symlinked destination on a root import, regardless of --override Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 18 +++++++++++-- .../component-writer.main.runtime.ts | 25 ++++++++++--------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index b7d992d7aab0..101764ffa821 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -299,12 +299,12 @@ describe('add command on Harmony', function () { const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); expect(cmd).to.throw('use --override'); }); - it('should report a dangling symlink as a conflict rather than write through it', () => { + it('should refuse a dangling symlink rather than write through it', () => { fs.rmdirSync(path.join(helper.scopes.localPath, 'README.md')); const target = path.join(helper.scopes.localPath, 'missing-target'); fs.symlinkSync(target, path.join(helper.scopes.localPath, 'README.md')); const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); - expect(cmd).to.throw('use --override'); + expect(cmd).to.throw('symbolic link'); expect(target).to.not.be.a.path(); }); it('should refuse a symlinked ancestor directory even with --override, rather than write through it', () => { @@ -317,6 +317,20 @@ describe('add command on Harmony', function () { expect(path.join(outside, 'guide.md')).to.not.be.a.path(); fs.removeSync(outside); }); + it('should refuse a symlinked destination even with --override, rather than write through it', () => { + fs.unlinkSync(path.join(helper.scopes.localPath, 'docs')); + const outsideFile = path.join( + helper.scopes.localPath, + '..', + `outside-${path.basename(helper.scopes.localPath)}.md` + ); + fs.writeFileSync(outsideFile, 'theirs\n'); + fs.symlinkSync(outsideFile, path.join(helper.scopes.localPath, 'README.md')); + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path . --override'); + expect(cmd).to.throw('symbolic link'); + expect(outsideFile).to.be.a.file().with.content('theirs\n'); + fs.removeSync(outsideFile); + }); }); describe('importing an ordinary component onto the root', () => { before(() => { diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index b011205aac3f..ea1d04c86e33 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -281,7 +281,7 @@ export class ComponentWriterMain { : this.consumer.composeRelativeComponentPath(component.id); if (componentRootDir === WORKSPACE_ROOT_DIR) { this.throwForNonWorkspaceRootComponent(component); - this.throwForSymlinkedAncestors(component); + this.throwForSymlinksInTheWay(component); } // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); @@ -340,22 +340,23 @@ to move all component files to a different directory, run bit remove and then bi } /** - * a workspace-root component's files land in the workspace tree itself, where a directory may be a - * symbolic link the user made. a write through it would land the file wherever the link points, so - * every existing ancestor of an incoming file has to be a real directory. this is about where the - * write goes, not what it replaces, so --override does not waive it. + * a workspace-root component's files land in the workspace tree itself, where a path may be a + * symbolic link the user made - a directory on the way, or the destination itself. a write through + * it would land the file wherever the link points, so every existing path on the way to an incoming + * file has to be real. this is about where the write goes, not what it replaces, so --override does + * not waive it (it waives the conflict check, which is the other place the destination is looked at). */ - private throwForSymlinkedAncestors(component: ConsumerComponent) { - const ancestors = new Set(); + private throwForSymlinksInTheWay(component: ConsumerComponent) { + const pathsInTheWay = new Set(); component.files.forEach((file) => { - const segments = pathNormalizeToLinux(file.relative).split('/').slice(0, -1); - segments.forEach((_, index) => ancestors.add(segments.slice(0, index + 1).join('/'))); + const segments = pathNormalizeToLinux(file.relative).split('/'); + segments.forEach((_, index) => pathsInTheWay.add(segments.slice(0, index + 1).join('/'))); }); - ancestors.forEach((ancestor) => { - const stat = lstatIfExists(this.consumer.toAbsolutePath(ancestor)); + pathsInTheWay.forEach((pathInTheWay) => { + const stat = lstatIfExists(this.consumer.toAbsolutePath(pathInTheWay)); if (!stat?.isSymbolicLink()) return; throw new BitError( - `unable to import "${component.id.toString()}" to the workspace root, "${ancestor}" is a symbolic link and the import would write through it` + `unable to import "${component.id.toString()}" to the workspace root, "${pathInTheWay}" is a symbolic link and the import would write through it` ); }); } From 9bbf81f8d0aff2b5046cd2ee1079010eb434a628 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 14:33:16 -0400 Subject: [PATCH 032/102] feat(tracker): default the workspace-root main file to workspace.jsonc The root component has no entry point of its own, so "bit add ." no longer needs --main; workspace.jsonc stands in for it, and an explicit --main still wins. The bulk tracking API defaults it the same way. Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 29 +++++++++----- scopes/component/tracker/add-components.ts | 9 ++++- .../tracker/determine-main-file.spec.ts | 40 +++++++++++++++++++ .../component/tracker/determine-main-file.ts | 13 +++++- .../component/tracker/tracker.main.runtime.ts | 2 +- 5 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 scopes/component/tracker/determine-main-file.spec.ts diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 101764ffa821..ba37ef4ca2e3 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -45,7 +45,7 @@ describe('add command on Harmony', function () { helper.scopeHelper.reInitWorkspace(); helper.fixtures.populateComponents(1); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.addComponent('.', { i: 'ws-root' }); // written after tracking. the root file-set is re-scanned, not frozen at add-time. helper.fs.outputFile('LICENSE', 'MIT\n'); rootFiles = helper.command.getComponentFiles('ws-root'); @@ -53,6 +53,9 @@ describe('add command on Harmony', function () { it('should save "." as the rootDir', () => { expect(helper.bitMap.read()['ws-root'].rootDir).to.equal('.'); }); + it('should default the main file to workspace.jsonc, the root has no entry point of its own', () => { + expect(helper.bitMap.read()['ws-root'].mainFile).to.equal('workspace.jsonc'); + }); it('should own the root files, including files added after it was tracked', () => { expect(rootFiles).to.include('README.md'); expect(rootFiles).to.include('LICENSE'); @@ -107,7 +110,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.addComponent('.', { i: 'ws-root' }); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); }); it('should not be modified right after snapping, despite tracking .bitmap', () => { @@ -144,7 +147,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); helper.fs.outputFile('untracked-by-bit.txt', 'not a component file\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.addComponent('.', { i: 'ws-root' }); // a dependency that happens to share the package name the root's id derives helper.fs.outputFile( path.join('node_modules', helper.general.getPackageNameByCompName('ws-root', false), 'index.js'), @@ -175,21 +178,25 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# workspace root\n'); helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); }); + it('should take the main file given explicitly over the default', () => { + expect(helper.bitMap.read()['ws-root'].mainFile).to.equal('README.md'); + }); it('should allow re-adding the same component', () => { helper.fs.outputFile('extra.md', 'extra\n'); - expect(() => helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' })).to.not.throw(); + expect(() => helper.command.addComponent('.', { i: 'ws-root' })).to.not.throw(); }); - it('should allow re-adding it without repeating its name', () => { - expect(() => helper.command.addComponent('.', { m: 'README.md' })).to.not.throw(); + it('should allow re-adding it without repeating its name, and keep its main file', () => { + expect(() => helper.command.addComponent('.')).to.not.throw(); expect(Object.keys(helper.bitMap.readComponentsMapOnly())).to.deep.equal(['ws-root']); + expect(helper.bitMap.read()['ws-root'].mainFile).to.equal('README.md'); }); it('should reject a second component claiming the workspace root', () => { - const cmd = () => helper.command.addComponent('.', { i: 'another-root', m: 'README.md' }); + const cmd = () => helper.command.addComponent('.', { i: 'another-root' }); expect(cmd).to.throw('already tracked by'); }); it('should pick up dotfiles at add time, not only on the next rescan', () => { helper.fs.outputFile('.npmrc', 'registry=https://example.com\n'); - const output = helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + const output = helper.command.addComponent('.', { i: 'ws-root' }); expect(output).to.have.string('.npmrc'); // its auto-generated banner must not get it dropped, the rescan tracks it expect(output).to.have.string('.bitmap'); @@ -220,7 +227,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); helper.fs.outputFile('docs/guide.md', '# guide\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.addComponent('.', { i: 'ws-root' }); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); firstSnap = helper.command.getHead('ws-root'); helper.fs.outputFile('README.md', '# workspace root v2\n'); @@ -369,7 +376,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.addComponent('.', { i: 'ws-root' }); }); it('should be tracked with the empty env, not the regular default env', () => { // it is a bag of the workspace's own config files - nothing compiles it, tests it, or @@ -425,7 +432,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('package.json', '{ "name": "monorepo", "private": true }\n'); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); + helper.command.addComponent('.', { i: 'ws-root' }); }); it('should track the package.json and tsconfig.json of a component', () => { expect(helper.command.getComponentFiles('comp1')).to.include.members(['package.json', 'tsconfig.json']); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 001dab1f80e7..f855d20889a0 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -15,6 +15,7 @@ import { AUTO_GENERATED_STAMP, IGNORE_ROOT_ONLY_LIST, Extensions, + WORKSPACE_JSONC, } from '@teambit/legacy.constants'; import type { BitMap, ComponentMapFile, Config } from '@teambit/legacy.bit-map'; import { @@ -867,7 +868,7 @@ export async function addMultipleFromResolvedTrackData( const batchRootDirs = trackData.map((data) => normalizeRootDir(data.rootDir)); const componentMaps: ComponentMap[] = []; for (const data of trackData) { - const { files, componentName, defaultScope, mainFile, config } = data; + const { files, componentName, defaultScope, config } = data; if (path.isAbsolute(data.rootDir)) throw new BitError(`path is absolute, got ${data.rootDir}`); const rootDir = normalizeRootDir(data.rootDir); const componentId = ComponentID.fromObject({ name: componentName }, defaultScope); @@ -878,6 +879,10 @@ export async function addMultipleFromResolvedTrackData( // the name alone would replace it and make a snapped component look newly tracked. const existingEntry = bitMap.getComponentIfExist(componentId, { ignoreVersion: true }); const idToTrack = existingEntry?.id ?? componentId; + // the workspace root has no entry point of its own, workspace.jsonc stands in for it, the way + // "bit add ." resolves it (see determine-main-file). re-tracking keeps the entry's main file. + const mainFile = data.mainFile ?? existingEntry?.mainFile ?? (isWorkspaceRoot ? WORKSPACE_JSONC : undefined); + if (!mainFile) throw new BitError(`unable to track "${rootDir}" as "${componentName}", no main file was given`); const existingConfig = isWorkspaceRoot ? existingEntry?.config : undefined; const componentFiles = isWorkspaceRoot ? await scanWorkspaceRootFiles(workspace, gitIgnore, batchRootDirs) @@ -917,7 +922,7 @@ function throwForTakingWorkspaceRootMainFile( // ownership lookups are case-insensitive, so this comparison is too. if (!workspaceRootMap.mainFile.toLowerCase().startsWith(`${rootDir.toLowerCase()}/`)) return; throw new BitError( - `unable to add "${rootDir}" as "${componentId.toString()}", it contains "${workspaceRootMap.mainFile}", the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ` + `unable to add "${rootDir}" as "${componentId.toString()}", it contains "${workspaceRootMap.mainFile}", the main file of the workspace-root component "${workspaceRootMap.id.toStringWithoutVersion()}". set a different main file for it first: bit add . --main ${WORKSPACE_JSONC}` ); } diff --git a/scopes/component/tracker/determine-main-file.spec.ts b/scopes/component/tracker/determine-main-file.spec.ts new file mode 100644 index 000000000000..20b4de214f8c --- /dev/null +++ b/scopes/component/tracker/determine-main-file.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { ComponentID } from '@teambit/component-id'; +import type { ComponentMap, ComponentMapFile } from '@teambit/legacy.bit-map'; +import determineMainFile from './determine-main-file'; +import type { AddedComponent } from './add-components'; + +const toFiles = (paths: string[]): ComponentMapFile[] => + paths.map((relativePath) => ({ relativePath, test: false, name: relativePath.split('/').pop() as string })); + +const addedComponent = (trackDir: string, files: string[], mainFile?: string): AddedComponent => ({ + componentId: ComponentID.fromObject({ name: 'comp' }, 'my-scope'), + files: toFiles(files), + mainFile, + trackDir, + idFromPath: null, + immediateDir: trackDir.split('/').pop(), +}); + +describe('determineMainFile', () => { + describe('the workspace root', () => { + // an index file at the root and a deeper one: neither is the entry point of a workspace + const rootFiles = ['.bitmap', 'README.md', 'index.ts', 'scripts/index.js', 'workspace.jsonc']; + it('should default to workspace.jsonc, over the index files', () => { + expect(determineMainFile(addedComponent('.', rootFiles), null)).to.equal('workspace.jsonc'); + }); + it('should take the main file the user gave over the default', () => { + expect(determineMainFile(addedComponent('.', rootFiles, 'README.md'), null)).to.equal('README.md'); + }); + it('should keep the main file of the existing entry when re-tracked without one', () => { + const existing = { rootDir: '.', mainFile: 'README.md' } as ComponentMap; + expect(determineMainFile(addedComponent('.', rootFiles), existing)).to.equal('README.md'); + }); + }); + describe('a regular directory', () => { + it('should resolve the index file, a workspace.jsonc inside it is a file like any other', () => { + const files = ['packages/comp1/index.ts', 'packages/comp1/workspace.jsonc']; + expect(determineMainFile(addedComponent('packages/comp1', files), null)).to.equal('packages/comp1/index.ts'); + }); + }); +}); diff --git a/scopes/component/tracker/determine-main-file.ts b/scopes/component/tracker/determine-main-file.ts index 6aedcc779876..7b011affa56e 100644 --- a/scopes/component/tracker/determine-main-file.ts +++ b/scopes/component/tracker/determine-main-file.ts @@ -5,11 +5,12 @@ import { DEFAULT_INDEX_EXTS, DEFAULT_INDEX_NAME, DEFAULT_SEPARATOR, + WORKSPACE_JSONC, } from '@teambit/legacy.constants'; import type { PathLinux } from '@teambit/legacy.utils'; import { pathJoinLinux, pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ComponentMap } from '@teambit/legacy.bit-map'; -import { MissingMainFile } from '@teambit/legacy.bit-map'; +import { MissingMainFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { AddedComponent } from './add-components'; export default function determineMainFile( @@ -23,6 +24,7 @@ export default function determineMainFile( const strategies: Function[] = [ getExistingIfNotChanged, getUserSpecifiedMainFile, + workspaceRootDefault, onlyOneFileEnteredUseIt, searchForFileNameIndex, searchForSameFileNameAsImmediateDir, @@ -66,6 +68,15 @@ export default function determineMainFile( } return null; } + /** + * the workspace-root component has no entry point of its own. workspace.jsonc, the file that makes + * the directory a workspace, stands in for it. it comes before the index search on purpose: an + * index file somewhere in the root's file-set is not the entry point of the workspace. + */ + function workspaceRootDefault(): PathLinux | null | undefined { + if (pathNormalizeToLinux(addedComponent.trackDir) !== WORKSPACE_ROOT_DIR) return null; + return files.find((file) => file.relativePath === WORKSPACE_JSONC)?.relativePath; + } /** * user didn't enter mainFile and the component has only one file, use that file as the main file */ diff --git a/scopes/component/tracker/tracker.main.runtime.ts b/scopes/component/tracker/tracker.main.runtime.ts index 722ca8e42b04..4790cb7486b2 100644 --- a/scopes/component/tracker/tracker.main.runtime.ts +++ b/scopes/component/tracker/tracker.main.runtime.ts @@ -42,7 +42,7 @@ export type TrackData = { export type ResolvedTrackData = { rootDir: PathLinuxRelative; // path relative to the workspace componentName: string; - mainFile: string; + mainFile?: string; // may be left out for the workspace root ("."), workspace.jsonc stands in for it files: string[]; // component files relative to the component rootDir defaultScope: string; config?: { [aspectName: string]: any }; // config specific to this component, which overrides variants of workspace.jsonc From 71d06d94ca3729e5f700f6915e02ed2325c600ba Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 14:40:44 -0400 Subject: [PATCH 033/102] test(e2e): assert a second root import is refused on local changes, not merely harmless Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index ba37ef4ca2e3..f400611138ac 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -272,14 +272,12 @@ describe('add command on Harmony', function () { // the exported .bitmap lists comp1. the restored workspace must not inherit that entry. expect(helper.bitMap.read()).to.not.have.property('comp1'); }); - it('should not overwrite a workspace.jsonc the user changed since, on a second import without --override', () => { - // the workspace is not fresh anymore: its root is tracked, and its config is the user's + it('should refuse a second import without --override once the user changed a root file, not overwrite it', () => { + // a changed root file makes the root a modified component, and the importer refuses modified + // components before anything is written. the writer's same-directory shortcut never sees it. helper.workspaceJsonc.addKeyValToWorkspace('name', 'renamed'); - try { - helper.command.importComponentWithoutInstall('ws-root', '--path .'); - } catch { - // refused, which is fine too - } + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); + expect(cmd).to.throw('due to local changes'); expect(helper.workspaceJsonc.read()['teambit.workspace/workspace'].name).to.equal('renamed'); }); }); From 3eeed3c75af93af74e83226e0420d66f39481f1d Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 15:34:21 -0400 Subject: [PATCH 034/102] fix(pkg): do not report missing node_modules links for the workspace-root component It is never linked into node_modules, so the issue and its "run bit link" advice cannot apply to it. Co-Authored-By: Claude Fable 5.1 --- e2e/harmony/add-harmony.e2e.ts | 12 ++++++++---- scopes/pkg/pkg/pkg.main.runtime.ts | 5 +++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index f400611138ac..fd90cb93f3e2 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -369,6 +369,10 @@ describe('add command on Harmony', function () { }); }); describe('env of the workspace-root component', () => { + const issuesOf = (name: string): string[] => { + const comp = helper.command.statusJson().componentsWithIssues.find((c) => c.id.includes(name)); + return comp ? comp.issues.map((issue) => issue.type) : []; + }; before(() => { helper.scopeHelper.reInitWorkspace(); helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); @@ -398,14 +402,14 @@ describe('add command on Harmony', function () { expect(helper.env.getComponentEnv('comp1')).to.equal('teambit.harmony/node'); }); it('should not report compiler-derived issues, while a regular component still does', () => { - const issuesOf = (name: string): string[] => { - const comp = helper.command.statusJson().componentsWithIssues.find((c) => c.id.includes(name)); - return comp ? comp.issues.map((issue) => issue.type) : []; - }; // the empty env has no compiler, so "missing dists" can never apply to the root component. expect(issuesOf('ws-root')).to.not.include('MissingDists'); expect(issuesOf('comp1')).to.include('MissingDists'); }); + it('should not report missing links from node_modules, it is never linked there', () => { + // "run bit link" is the suggested fix for that issue, and it would not link the root either + expect(issuesOf('ws-root')).to.not.include('MissingLinksFromNodeModulesToSrc'); + }); describe('when a root file has a relative import into a component', () => { before(() => { helper.fs.outputFile('app.js', "const comp1 = require('./comp1');\n"); diff --git a/scopes/pkg/pkg/pkg.main.runtime.ts b/scopes/pkg/pkg/pkg.main.runtime.ts index caa3a9cd38f0..6687a61b7d46 100644 --- a/scopes/pkg/pkg/pkg.main.runtime.ts +++ b/scopes/pkg/pkg/pkg.main.runtime.ts @@ -18,6 +18,7 @@ import { ScopeAspect } from '@teambit/scope'; import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; import { PackageJsonTransformer } from '@teambit/workspace.modules.node-modules-linker'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { BuilderMain } from '@teambit/builder'; import { BuilderAspect } from '@teambit/builder'; import { BitError } from '@teambit/bit-error'; @@ -253,6 +254,10 @@ export class PkgMain { } async addMissingLinksFromNodeModulesIssue(component: Component) { + // the workspace-root component is the workspace itself, not a package. it is never linked into + // node_modules (see NodeModuleLinker.link), so a missing link is not something to fix for it. + const componentMap = this.workspace.bitMap.getBitmapEntryIfExist(component.id, { ignoreVersion: true }); + if (componentMap?.rootDir === WORKSPACE_ROOT_DIR) return undefined; const exist = this.isModulePathExists(component); if (!exist) { component.state.issues.getOrCreate(IssuesClasses.MissingLinksFromNodeModulesToSrc).data = true; From 0d90dd013ca60453852f419e7905af6e947ff1e1 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 17:58:52 -0400 Subject: [PATCH 035/102] fix(bit-map): read a component's own ignore file from disk, apply the scan exclusions to bulk-tracked paths, skip the root in the duplicate-package check --- .../legacy/bit-map/component-map.spec.ts | 27 ++++++++++++++++++- components/legacy/bit-map/component-map.ts | 23 ++++++++++------ components/legacy/bit-map/index.ts | 1 + e2e/harmony/add-harmony.e2e.ts | 6 +++++ scopes/component/tracker/add-components.ts | 8 +++++- .../workspace/install/install.main.runtime.ts | 3 +++ 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index 48606e86f086..c5264033018f 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -3,7 +3,14 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { BIT_HIDDEN_DIR, BIT_WORKSPACE_TMP_DIRNAME, DOT_GIT_DIR } from '@teambit/legacy.constants'; -import { getFilesByDir, getGitIgnoreHarmony, getIgnoreListHarmony, WORKSPACE_ROOT_DIR } from './component-map'; +import { + filterByOwnIgnoreFile, + filterByScanIgnorePatterns, + getFilesByDir, + getGitIgnoreHarmony, + getIgnoreListHarmony, + WORKSPACE_ROOT_DIR, +} from './component-map'; const createWorkspace = async (prefix: string, files: Record): Promise => { const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -113,6 +120,24 @@ describe('getFilesByDir', function () { }); after(() => Promise.all([fs.remove(workspacePath), fs.remove(outsidePath)])); + it("should apply a nested component's own .bitignore although the workspace rules hide that file from the list", async () => { + // the root .gitignore ignores every nested .bitignore, so it is not among the paths handed over + const filtered = await filterByOwnIgnoreFile('vendor/lib', workspacePath, ['index.js', 'generated/x.js']); + expect(filtered).to.deep.equal(['index.js']); + }); + it('should drop from caller-resolved paths what a scan never yields', () => { + const dir = 'packages/comp1'; + const paths = [ + `${dir}/index.ts`, + `${dir}/.bitmap`, + `${dir}/${DOT_GIT_DIR}/HEAD`, + `${dir}/${BIT_HIDDEN_DIR}/objects/aa`, + `${dir}/${BIT_WORKSPACE_TMP_DIRNAME}/x`, + `${dir}/node_modules/dep/index.js`, + ]; + expect(filterByScanIgnorePatterns(dir, paths)).to.deep.equal([`${dir}/index.ts`]); + }); + // bit runs with the workspace as its cwd. globby stats ignore patterns relative to the process // cwd, so the `.git` pointer file is only exercised from inside the workspace. const scanFromInsideTheWorkspace = async (dir: string, excludeDirs: string[]): Promise => { diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 150e2cc472b8..6801dcd0f4fd 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -132,10 +132,12 @@ export async function filterByIgnoreFiles( /** * the component's own ignore file (.bitignore, else .gitignore, at its root), applied to its files. - * resolved against the workspace, not the process cwd: `dir` is workspace-relative, so running bit - * from a sub-directory would otherwise look in the wrong place - and getBitIgnoreFile() does not - * swallow ENOENT. not for the workspace root: its own file is the workspace's, part of `gitIgnore` - * and evaluated together with the nested ones - applied again on its own it would undo their negations. + * looked up on disk, not in `relativePaths`: the workspace rules may hide the file itself from the + * scanned list (a root .gitignore that ignores every nested .bitignore), and the component's rules still apply, as in + * git. resolved against the workspace, not the process cwd: `dir` is workspace-relative, so running + * bit from a sub-directory would otherwise look in the wrong place. not for the workspace root: its + * own file is the workspace's, part of `gitIgnore` and evaluated together with the nested ones - + * applied again on its own it would undo their negations. */ export async function filterByOwnIgnoreFile( dir: PathLinux, @@ -143,13 +145,18 @@ export async function filterByOwnIgnoreFile( relativePaths: PathLinux[] ): Promise { if (dir === WORKSPACE_ROOT_DIR) return relativePaths; - const ignoreFileDir = path.join(consumerPath, dir); - const ownIgnoreFile = relativePaths.includes(BIT_IGNORE) - ? await getBitIgnoreFile(ignoreFileDir) - : await getGitIgnoreFile(ignoreFileDir); + const ownIgnoreFile = await retrieveUserIgnoreList(path.join(consumerPath, dir)); return ownIgnoreFile.length ? ignore().add(ownIgnoreFile).filter(relativePaths) : relativePaths; } +/** + * the paths a scan never yields - bit's own dirs, git's, a nested workspace map - applied to + * workspace-relative paths a caller resolved itself, so what it tracks is what the next rescan keeps. + */ +export function filterByScanIgnorePatterns(dir: PathLinux, workspaceRelativePaths: PathLinux[]): PathLinux[] { + return ignore().add(getScanIgnorePatterns(dir)).filter(workspaceRelativePaths); +} + async function getNestedIgnorePatterns( consumerPath: string, gitIgnore: any, diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index a13838dcf567..3add5ba0a4f8 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -17,6 +17,7 @@ export { Config, filterByIgnoreFiles, filterByOwnIgnoreFile, + filterByScanIgnorePatterns, getFilesByDir, getIgnoreListHarmony, getScanIgnorePatterns, diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index fd90cb93f3e2..6057e467fc4f 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -410,6 +410,12 @@ describe('add command on Harmony', function () { // "run bit link" is the suggested fix for that issue, and it would not link the root either expect(issuesOf('ws-root')).to.not.include('MissingLinksFromNodeModulesToSrc'); }); + it('should not report a duplicate component-and-package issue for the root, it is not a package', () => { + // the default remote scope of the e2e has no owner prefix, so the package name has none either + const wouldBePackageName = helper.general.getPackageNameByCompName('ws-root', false); + helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { [wouldBePackageName]: '1.0.0' } }); + expect(issuesOf('ws-root')).to.not.include('DuplicateComponentAndPackage'); + }); describe('when a root file has a relative import into a component', () => { before(() => { helper.fs.outputFile('app.js', "const comp1 = require('./comp1');\n"); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index f855d20889a0..15ced1c99275 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -22,6 +22,7 @@ import { ComponentMap, filterByIgnoreFiles, filterByOwnIgnoreFile, + filterByScanIgnorePatterns, getFilesByDir, getIgnoreListHarmony, getScanIgnorePatterns, @@ -962,7 +963,12 @@ async function filterResolvedFiles( const notGenerated = files .map(pathNormalizeToLinux) .filter((file) => trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(file)); - const workspaceRelative = notGenerated.map((file) => path.posix.join(rootDir, file)); + // what a scan never yields (bit's and git's own dirs, a nested workspace map) is not tracked here either, + // or the next rescan would drop it + const workspaceRelative = filterByScanIgnorePatterns( + rootDir, + notGenerated.map((file) => path.posix.join(rootDir, file)) + ); const filteredByWorkspaceRules: string[] = gitIgnore .filter(workspaceRelative) .map((file) => path.posix.relative(rootDir, file)); diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index bf90f5b4584d..0940525f56d2 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -1365,6 +1365,9 @@ export class InstallMain { const workspacePolicy = this.dependencyResolver.getWorkspacePolicy(); components.forEach((component) => { if (component.state._consumer.removed) return; + // the workspace-root component is not a package (see getComponentsDirectory): a workspace dependency + // by the name it would have had is no duplicate of it + if (this.workspace.componentDir(component.id) === this.workspace.path) return; const pkgName = componentIdToPackageName(component.state._consumer); const found = workspacePolicy.find(pkgName); if (found) { From 481443db32bc3842ad3b9b97804283c2e62a9a40 Mon Sep 17 00:00:00 2001 From: David First Date: Mon, 14 Sep 2026 18:23:24 -0400 Subject: [PATCH 036/102] fix(bit-map): prefer the scope-qualified map key when checking the workspace-root owner --- components/legacy/bit-map/bit-map.spec.ts | 9 +++++++++ components/legacy/bit-map/bit-map.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 59614d491428..1a9920f4b5d0 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -258,6 +258,15 @@ describe('BitMap', function () { it('should reject the same name from another scope', () => { expect(isWorkspaceMapOwnedBy(rawMap, id('ws-root', 'other-scope'))).to.be.false; }); + it('should find the owner under its scope-qualified key when another scope took the bare name', () => { + // the map keys duplicate names by "scope/name". the bare key is then the other scope's component + const rawMapWithDuplicateName = JSON.stringify({ + 'ws-root': { scope: 'other-scope', version: '', mainFile: 'index.ts', rootDir: 'packages/other-root' }, + 'my-scope/ws-root': { scope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }, + }); + expect(isWorkspaceMapOwnedBy(rawMapWithDuplicateName, id('ws-root'))).to.be.true; + expect(isWorkspaceMapOwnedBy(rawMapWithDuplicateName, id('ws-root', 'other-scope'))).to.be.false; + }); it('should reject a component the map lists elsewhere, or not at all, and a map it cannot parse', () => { expect(isWorkspaceMapOwnedBy(rawMap, id('comp1'))).to.be.false; expect(isWorkspaceMapOwnedBy(rawMap, id('other'))).to.be.false; diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index cef857326896..1d99d9a71720 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1124,7 +1124,9 @@ export function isWorkspaceMapOwnedBy(rawContent: Buffer | string, id: Component } catch { return false; } - const entry = parsed?.[id.fullName] ?? parsed?.[id.toStringWithoutVersion()]; + // the map keys an entry by name, and by "scope/name" when another scope has the same name. the + // qualified key is checked first: with both present, the bare one is the other scope's component. + const entry = parsed?.[id.toStringWithoutVersion()] ?? parsed?.[id.fullName]; if (!entry || typeof entry !== 'object' || entry.rootDir !== WORKSPACE_ROOT_DIR) return false; // an entry snapped before its export carries its scope as defaultScope. either way it has to be the // scope the component comes from - an empty one is not a wildcard. From c0cb2f7e237a728eab6c954e4a096fa68cce9b75 Mon Sep 17 00:00:00 2001 From: David First Date: Tue, 15 Sep 2026 09:41:08 -0400 Subject: [PATCH 037/102] fix(install): run the rewire codemod on the workspace-root component as well --- e2e/harmony/add-harmony.e2e.ts | 10 ++++++++++ scopes/workspace/install/install.main.runtime.ts | 13 ++++++++++--- .../node-modules-linker/node-modules-linker.ts | 9 +++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 6057e467fc4f..a9908e2c6e46 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -425,6 +425,16 @@ describe('add command on Harmony', function () { // "this error should have never happened" failure when the Version object is saved. expect(helper.command.getAllIssuesFromStatus()).to.include('RelativeComponentsAuthored'); }); + describe('bit link --rewire', () => { + before(() => { + helper.command.linkAndRewire(); + }); + it('should rewrite the import to the package name, although the root is never linked itself', () => { + const packageName = helper.general.getPackageNameByCompName('comp1', false); + expect(helper.fs.readFile('app.js')).to.equal(`const comp1 = require('${packageName}');\n`); + expect(helper.command.getAllIssuesFromStatus()).to.not.include('RelativeComponentsAuthored'); + }); + }); }); }); describe('trackAllFiles: tracking the files bit treats as generated', () => { diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 0940525f56d2..0e4ff5630b65 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -1434,7 +1434,7 @@ export class InstallMain { ); const workspaceRes = res as WorkspaceLinkResults; - const legacyResults = await this.linkCodemods(compDirMap, options); + const legacyResults = await this.linkCodemods(compDirMap, options, ids); workspaceRes.legacyLinkResults = legacyResults.linksResults; workspaceRes.legacyLinkCodemodResults = legacyResults.codemodResults; @@ -1461,9 +1461,16 @@ export class InstallMain { ); } - async linkCodemods(compDirMap: ComponentMap, options?: { rewire?: boolean }) { + /** + * `compDirMap` holds the components that are packages (see getComponentsDirectory), which is what + * the node_modules links are for. the rewire codemod is about source files instead: the + * workspace-root component's own files can have relative imports into the components nested in + * it just like any other component, so the codemod runs on the ids that were asked for, an empty + * list meaning the whole workspace. + */ + async linkCodemods(compDirMap: ComponentMap, options?: { rewire?: boolean }, requestedIds?: ComponentID[]) { const bitIds = compDirMap.toArray().map(([component]) => component.id); - return linkToNodeModulesWithCodemod(this.workspace, bitIds, options?.rewire ?? false); + return linkToNodeModulesWithCodemod(this.workspace, bitIds, options?.rewire ?? false, requestedIds); } async link(ids: string[], options: WorkspaceLinkOptions = {}): Promise { diff --git a/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts b/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts index 5aaf6604ad74..c407f3336893 100644 --- a/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts +++ b/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts @@ -333,14 +333,19 @@ export default class NodeModuleLinker { } } +/** + * @param idsToRewire the components the codemod rewrites. defaults to the linked ones. an empty + * list means every component in the workspace. + */ export async function linkToNodeModulesWithCodemod( workspace: Workspace, bitIds: ComponentID[], - changeRelativeToModulePaths: boolean + changeRelativeToModulePaths: boolean, + idsToRewire: ComponentID[] = bitIds ) { let codemodResults; if (changeRelativeToModulePaths) { - codemodResults = await changeCodeFromRelativeToModulePaths(workspace, bitIds); + codemodResults = await changeCodeFromRelativeToModulePaths(workspace, idsToRewire); } const linksResults = await linkToNodeModulesByIds(workspace, bitIds); return { linksResults, codemodResults }; From 401b4e4119d95704bc8dd353b78289e7ed8a5b36 Mon Sep 17 00:00:00 2001 From: David First Date: Tue, 15 Sep 2026 11:13:04 -0400 Subject: [PATCH 038/102] revert: fix(install): run the rewire codemod on the workspace-root component as well The root component's files are no longer parsed for dependencies, so the relative-import issue that the codemod fixes cannot be raised for it. --- e2e/harmony/add-harmony.e2e.ts | 10 ---------- scopes/workspace/install/install.main.runtime.ts | 13 +++---------- .../node-modules-linker/node-modules-linker.ts | 9 ++------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index a9908e2c6e46..6057e467fc4f 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -425,16 +425,6 @@ describe('add command on Harmony', function () { // "this error should have never happened" failure when the Version object is saved. expect(helper.command.getAllIssuesFromStatus()).to.include('RelativeComponentsAuthored'); }); - describe('bit link --rewire', () => { - before(() => { - helper.command.linkAndRewire(); - }); - it('should rewrite the import to the package name, although the root is never linked itself', () => { - const packageName = helper.general.getPackageNameByCompName('comp1', false); - expect(helper.fs.readFile('app.js')).to.equal(`const comp1 = require('${packageName}');\n`); - expect(helper.command.getAllIssuesFromStatus()).to.not.include('RelativeComponentsAuthored'); - }); - }); }); }); describe('trackAllFiles: tracking the files bit treats as generated', () => { diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 0e4ff5630b65..0940525f56d2 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -1434,7 +1434,7 @@ export class InstallMain { ); const workspaceRes = res as WorkspaceLinkResults; - const legacyResults = await this.linkCodemods(compDirMap, options, ids); + const legacyResults = await this.linkCodemods(compDirMap, options); workspaceRes.legacyLinkResults = legacyResults.linksResults; workspaceRes.legacyLinkCodemodResults = legacyResults.codemodResults; @@ -1461,16 +1461,9 @@ export class InstallMain { ); } - /** - * `compDirMap` holds the components that are packages (see getComponentsDirectory), which is what - * the node_modules links are for. the rewire codemod is about source files instead: the - * workspace-root component's own files can have relative imports into the components nested in - * it just like any other component, so the codemod runs on the ids that were asked for, an empty - * list meaning the whole workspace. - */ - async linkCodemods(compDirMap: ComponentMap, options?: { rewire?: boolean }, requestedIds?: ComponentID[]) { + async linkCodemods(compDirMap: ComponentMap, options?: { rewire?: boolean }) { const bitIds = compDirMap.toArray().map(([component]) => component.id); - return linkToNodeModulesWithCodemod(this.workspace, bitIds, options?.rewire ?? false, requestedIds); + return linkToNodeModulesWithCodemod(this.workspace, bitIds, options?.rewire ?? false); } async link(ids: string[], options: WorkspaceLinkOptions = {}): Promise { diff --git a/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts b/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts index c407f3336893..5aaf6604ad74 100644 --- a/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts +++ b/scopes/workspace/modules/node-modules-linker/node-modules-linker.ts @@ -333,19 +333,14 @@ export default class NodeModuleLinker { } } -/** - * @param idsToRewire the components the codemod rewrites. defaults to the linked ones. an empty - * list means every component in the workspace. - */ export async function linkToNodeModulesWithCodemod( workspace: Workspace, bitIds: ComponentID[], - changeRelativeToModulePaths: boolean, - idsToRewire: ComponentID[] = bitIds + changeRelativeToModulePaths: boolean ) { let codemodResults; if (changeRelativeToModulePaths) { - codemodResults = await changeCodeFromRelativeToModulePaths(workspace, idsToRewire); + codemodResults = await changeCodeFromRelativeToModulePaths(workspace, bitIds); } const linksResults = await linkToNodeModulesByIds(workspace, bitIds); return { linksResults, codemodResults }; From 1208b926cb07b5181481a2e1ebe8f18449c8af28 Mon Sep 17 00:00:00 2001 From: David First Date: Tue, 15 Sep 2026 11:18:41 -0400 Subject: [PATCH 039/102] feat(dependencies): skip dependency detection for the workspace-root component --- e2e/harmony/add-harmony.e2e.ts | 18 ++++++++++++------ .../dependencies-loader.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 6057e467fc4f..cf3270ef974b 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -416,14 +416,20 @@ describe('add command on Harmony', function () { helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { [wouldBePackageName]: '1.0.0' } }); expect(issuesOf('ws-root')).to.not.include('DuplicateComponentAndPackage'); }); - describe('when a root file has a relative import into a component', () => { + describe('when a root file has a relative import into a component and requires a missing package', () => { before(() => { - helper.fs.outputFile('app.js', "const comp1 = require('./comp1');\n"); + helper.fs.outputFile('app.js', "require('./comp1');\nrequire('some-package-that-is-not-installed');\n"); }); - it('should report the relative-import issue like any other component', () => { - // the root component is not exempt from this one. without it, the user gets an - // "this error should have never happened" failure when the Version object is saved. - expect(helper.command.getAllIssuesFromStatus()).to.include('RelativeComponentsAuthored'); + it('should report no issue, the root files are not parsed for dependencies', () => { + // the root is the workspace itself: config files and repo scripts that may require anything. + // nothing installs, links or builds it, so nothing would consume its dependency list either. + expect(issuesOf('ws-root')).to.deep.equal([]); + }); + it('should snap with no dependencies', () => { + helper.command.snapComponentWithoutBuild('ws-root'); + const versionObject = helper.command.catComponent('ws-root@latest'); + expect(versionObject.dependencies).to.deep.equal([]); + expect(versionObject.packageDependencies).to.deep.equal({}); }); }); }); diff --git a/scopes/dependencies/dependencies/dependencies-loader/dependencies-loader.ts b/scopes/dependencies/dependencies/dependencies-loader/dependencies-loader.ts index 31a11ee5247d..a980c3c58689 100644 --- a/scopes/dependencies/dependencies/dependencies-loader/dependencies-loader.ts +++ b/scopes/dependencies/dependencies/dependencies-loader/dependencies-loader.ts @@ -9,6 +9,7 @@ import { ExtensionDataEntry } from '@teambit/legacy.extension-data'; import type { DependencyLoaderOpts, ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { COMPONENT_CONFIG_FILE_NAME } from '@teambit/legacy.constants'; import type { Workspace } from '@teambit/workspace'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { DependencyResolverMain } from '@teambit/dependency-resolver'; import { DependencyResolverAspect } from '@teambit/dependency-resolver'; import type { DevFilesMain } from '@teambit/dev-files'; @@ -78,6 +79,15 @@ export class DependenciesLoader { dependenciesData: DependenciesData; debugDependenciesData?: DebugDependencies; }> { + // the workspace-root component (rootDir ".") is the workspace itself: its config files, lockfile + // and repo scripts. it is never installed, linked or built, so nothing consumes its dependency + // list, while its files are free to require anything, relative paths into the components nested + // in it included. parsing them would only produce issues that block the snap, and the + // missing-packages ones could never be fixed - "bit install" leaves its dir out of the component + // manifests. explicit overrides, such as "bit deps set", still apply. + if (this.component.componentMap?.rootDir === WORKSPACE_ROOT_DIR) { + return { dependenciesData: this.getEmptyDependenciesData() }; + } const depsDataFromCache = await this.getDependenciesDataFromCacheIfPossible(workspace, opts); if (depsDataFromCache) { return { dependenciesData: depsDataFromCache }; @@ -101,6 +111,15 @@ export class DependenciesLoader { return results; } + private getEmptyDependenciesData(): DependenciesData { + return new DependenciesData( + { dependencies: [], devDependencies: [], peerDependencies: [] }, + { packageDependencies: {}, devPackageDependencies: {}, peerPackageDependencies: {} }, + this.component.issues, + [] + ); + } + private async getDependenciesDataFromCacheIfPossible( workspace: Workspace, opts: DependencyLoaderOpts From a6fd3a9ba26daaf1e21cdf1fc4ad8ad74ffcfa93 Mon Sep 17 00:00:00 2001 From: David First Date: Tue, 15 Sep 2026 17:50:27 -0400 Subject: [PATCH 040/102] feat(workspace-root): new aspect, members record the root component they were snapped in --- .bitmap | 14 +++ e2e/harmony/add-harmony.e2e.ts | 13 +++ pnpm-lock.yaml | 102 ++++++++++++++++++ scopes/component/snapping/version-maker.ts | 19 ++++ scopes/harmony/bit/manifests.ts | 2 + .../testing/load-aspect/core-aspects-ids.json | 1 + scopes/workspace/workspace-root/index.ts | 4 + .../workspace-root-data.spec.ts | 49 +++++++++ .../workspace-root/workspace-root-data.ts | 43 ++++++++ .../workspace-root/workspace-root.aspect.ts | 7 ++ .../workspace-root/workspace-root.docs.mdx | 28 +++++ .../workspace-root/workspace-root.fragment.ts | 22 ++++ .../workspace-root.main.runtime.ts | 59 ++++++++++ 13 files changed, 363 insertions(+) create mode 100644 scopes/workspace/workspace-root/index.ts create mode 100644 scopes/workspace/workspace-root/workspace-root-data.spec.ts create mode 100644 scopes/workspace/workspace-root/workspace-root-data.ts create mode 100644 scopes/workspace/workspace-root/workspace-root.aspect.ts create mode 100644 scopes/workspace/workspace-root/workspace-root.docs.mdx create mode 100644 scopes/workspace/workspace-root/workspace-root.fragment.ts create mode 100644 scopes/workspace/workspace-root/workspace-root.main.runtime.ts diff --git a/.bitmap b/.bitmap index 02d891bbafe8..2f32a3d10b49 100644 --- a/.bitmap +++ b/.bitmap @@ -2312,6 +2312,20 @@ "mainFile": "index.ts", "rootDir": "scopes/workspace/workspace-config-files" }, + "workspace-root": { + "name": "workspace-root", + "scope": "", + "version": "", + "defaultScope": "teambit.workspace", + "mainFile": "index.ts", + "rootDir": "scopes/workspace/workspace-root", + "config": { + "teambit.harmony/envs/core-aspect-env@2.1.1": {}, + "teambit.envs/envs": { + "env": "teambit.harmony/envs/core-aspect-env" + } + } + }, "yarn": { "name": "yarn", "scope": "teambit.dependencies", diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index cf3270ef974b..e29aee588843 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -122,6 +122,19 @@ describe('add command on Harmony', function () { const quickStatus = JSON.parse(helper.command.runCmd('bit status --quick --json')); expect(quickStatus.modified).to.have.lengthOf(0); }); + it('should record on the nested component the root it was snapped in, at the version the root got in that snap', () => { + // aspect data written by the snap, so it travels with the component to any scope. a ci or a + // clone uses it to fetch the root files (lockfile, tsconfig, scripts) this version was made with. + const rootHead = helper.command.getHead('ws-root'); + const rootData = helper.command + .catComponent('comp1@latest') + .extensions.find((ext) => ext.name === 'teambit.workspace/workspace-root')?.data; + expect(rootData).to.deep.equal({ root: `${helper.scopes.remote}/ws-root@${rootHead}` }); + }); + it('should not record it on the root component itself', () => { + const extensionNames = helper.command.catComponent('ws-root@latest').extensions.map((ext) => ext.name); + expect(extensionNames).to.not.include('teambit.workspace/workspace-root'); + }); describe('adding a new component inside the workspace root', () => { before(() => { helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1381c63875b0..74739d8b0ac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7636,6 +7636,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@5.2.1)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@5.2.1)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@19.2.7) @@ -8701,6 +8704,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@19.2.7) @@ -9754,6 +9760,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@18.3.1) @@ -10807,6 +10816,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@19.2.7) @@ -11842,6 +11854,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@19.2.7) @@ -12877,6 +12892,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@19.2.7) @@ -25620,6 +25638,43 @@ importers: specifier: ^3.0.0 version: 3.0.2 + scopes/workspace/workspace-root: + dependencies: + '@teambit/component-id': + specifier: ^1.2.4 + version: 1.2.4 + '@teambit/harmony': + specifier: 0.4.12 + version: 0.4.12 + '@testing-library/react': + specifier: ^14.3.1 + version: 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) + '@types/node': + specifier: 22.10.5 + version: 22.10.5 + '@types/react': + specifier: ^19.0.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.4(@types/react@19.2.18) + chai: + specifier: 5.2.1 + version: 5.2.1 + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + react-router-dom: + specifier: ^6.30.1 + version: 6.30.4(react-dom@19.2.7)(react@19.2.7) + devDependencies: + '@teambit/harmony.envs.core-aspect-env': + specifier: 2.1.1 + version: 2.1.1(@babel/core@7.28.3)(@babel/traverse@7.29.8)(eslint@8.56.0)(react-refresh@0.10.0)(react-test-renderer@19.2.7)(rollup@2.80.0)(sass-embedded@1.102.0)(supports-color@9.4.0)(webpack@5.109.2) + packages: '@adobe/css-tools@4.5.0': @@ -34293,6 +34348,11 @@ packages: peerDependencies: chai: 5.2.1 + '@teambit/workspace-root@file:scopes/workspace/workspace-root': + resolution: {directory: scopes/workspace/workspace-root, type: directory} + peerDependencies: + chai: 5.2.1 + '@teambit/workspace.aspect-docs.variants@file:scopes/workspace/aspect-docs/variants': resolution: {directory: scopes/workspace/aspect-docs/variants, type: directory} peerDependencies: @@ -83340,6 +83400,48 @@ snapshots: - graphql - supports-color + '@teambit/workspace-root@file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0)': + dependencies: + '@teambit/component-id': 1.2.4 + '@teambit/harmony': 0.4.12 + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.consumer-component': file:components/legacy/consumer-component(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.extension-data': file:components/legacy/extension-data + '@testing-library/react': 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) + '@types/node': 22.10.5 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + chai: 4.3.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router-dom: 6.30.4(react-dom@19.2.7)(react@19.2.7) + transitivePeerDependencies: + - domexception + - encoding + - graphql + - supports-color + + '@teambit/workspace-root@file:scopes/workspace/workspace-root(chai@5.2.1)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0)': + dependencies: + '@teambit/component-id': 1.2.4 + '@teambit/harmony': 0.4.12 + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.consumer-component': file:components/legacy/consumer-component(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.extension-data': file:components/legacy/extension-data + '@testing-library/react': 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) + '@types/node': 22.10.5 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + chai: 5.2.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router-dom: 6.30.4(react-dom@19.2.7)(react@19.2.7) + transitivePeerDependencies: + - domexception + - encoding + - graphql + - supports-color + '@teambit/workspace.aspect-docs.variants@file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@18.3.1)': dependencies: '@teambit/documenter.theme.theme-compositions': 4.1.5(react-dom@18.3.1)(react@18.3.1) diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 45758bb98134..bff74400de6b 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -33,6 +33,7 @@ import { DependencyResolverAspect, COMPONENT_DEP_TYPE } from '@teambit/dependenc import type { Registries } from '@teambit/pkg.entities.registry'; import type { ScopeMain, StagedConfig } from '@teambit/scope'; import type { Workspace, AutoTagResult } from '@teambit/workspace'; +import { findWorkspaceRootMap, writeWorkspaceRoot } from '@teambit/workspace-root'; import { pMapPool } from '@teambit/toolbox.promise.map-pool'; import type { PackageIntegritiesByPublishedPackages, SnappingMain, TagDataPerComp } from './snapping.main.runtime'; import type { LaneId } from '@teambit/lane-id'; @@ -148,6 +149,7 @@ export class VersionMaker { this.params.isSnap ? this.setHashes() : await this.setFutureVersions(autoTagIds); // go through all dependencies and update their versions this.updateDependenciesVersions(); + this.recordWorkspaceRoot(); await this.addLogToComponents(componentsToTag, autoTagComponents, messagePerId); // don't move it down. otherwise, it'll be empty and we don't know which components were during merge. // (it's being deleted in snapping.main.runtime - `_addCompToObjects` method) @@ -752,6 +754,23 @@ export class VersionMaker { }); } + /** + * every member of a workspace-root component records the root it is snapped in, at the version the + * root has after this batch: its new version when it is snapped along, otherwise the one in .bitmap + * (none when the root was never snapped). the root itself records nothing. see WorkspaceRootMain. + */ + private recordWorkspaceRoot() { + if (!this.consumer) return; + const rootMap = findWorkspaceRootMap(this.consumer.bitMap); + if (!rootMap) return; + const rootInBatch = this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); + const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap.id; + this.allComponentsToTag.forEach((component) => { + if (component.id.isEqualWithoutVersion(rootMap.id)) return; + writeWorkspaceRoot(component.extensions, rootId); + }); + } + private async addLogToComponents( components: ConsumerComponent[], autoTagComps: ConsumerComponent[], diff --git a/scopes/harmony/bit/manifests.ts b/scopes/harmony/bit/manifests.ts index ddc139168c4b..8b6d0c0dea16 100644 --- a/scopes/harmony/bit/manifests.ts +++ b/scopes/harmony/bit/manifests.ts @@ -49,6 +49,7 @@ import { WebpackAspect } from '@teambit/webpack'; import { WorkspaceAspect } from '@teambit/workspace'; import { WorkspaceConfigFilesAspect } from '@teambit/workspace-config-files'; import { InstallAspect } from '@teambit/install'; +import { WorkspaceRootAspect } from '@teambit/workspace-root'; import { LinterAspect } from '@teambit/linter'; import { FormatterAspect } from '@teambit/formatter'; import { ValidatorAspect } from '@teambit/validator'; @@ -125,6 +126,7 @@ export const manifestsMap = { [WorkspaceAspect.id]: WorkspaceAspect, [WorkspaceConfigFilesAspect.id]: WorkspaceConfigFilesAspect, [InstallAspect.id]: InstallAspect, + [WorkspaceRootAspect.id]: WorkspaceRootAspect, [ESLintAspect.id]: ESLintAspect, [PrettierAspect.id]: PrettierAspect, [CompilerAspect.id]: CompilerAspect, diff --git a/scopes/harmony/testing/load-aspect/core-aspects-ids.json b/scopes/harmony/testing/load-aspect/core-aspects-ids.json index 086e44ed9e89..7dd2684bc84c 100644 --- a/scopes/harmony/testing/load-aspect/core-aspects-ids.json +++ b/scopes/harmony/testing/load-aspect/core-aspects-ids.json @@ -5,6 +5,7 @@ "teambit.workspace/workspace", "teambit.workspace/workspace-config-files", "teambit.workspace/install", + "teambit.workspace/workspace-root", "teambit.defender/eslint", "teambit.defender/prettier", "teambit.compilation/compiler", diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts new file mode 100644 index 000000000000..8d78a954ad0d --- /dev/null +++ b/scopes/workspace/workspace-root/index.ts @@ -0,0 +1,4 @@ +export { WorkspaceRootAspect, default } from './workspace-root.aspect'; +export type { WorkspaceRootMain } from './workspace-root.main.runtime'; +export type { WorkspaceRootData } from './workspace-root-data'; +export { findWorkspaceRootMap, readWorkspaceRoot, writeWorkspaceRoot } from './workspace-root-data'; diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts new file mode 100644 index 000000000000..9dce9a9c3a07 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { ComponentID } from '@teambit/component-id'; +import type { BitMap } from '@teambit/legacy.bit-map'; +import { ExtensionDataList } from '@teambit/legacy.extension-data'; +import { findWorkspaceRootMap, readWorkspaceRoot, writeWorkspaceRoot } from './workspace-root-data'; + +const rootId = ComponentID.fromString('my-scope/my-root@0.0.7'); + +describe('workspace-root data', () => { + describe('findWorkspaceRootMap', () => { + it('should return the entry tracked at the workspace root', () => { + const bitMap = { + components: [ + { id: ComponentID.fromString('my-scope/comp1'), rootDir: 'comp1' }, + { id: rootId, rootDir: '.' }, + ], + } as unknown as BitMap; + expect(findWorkspaceRootMap(bitMap)?.id.toString()).to.equal(rootId.toString()); + }); + it('should return undefined when no component owns the workspace root', () => { + const bitMap = { + components: [{ id: ComponentID.fromString('my-scope/comp1'), rootDir: 'comp1' }], + } as unknown as BitMap; + expect(findWorkspaceRootMap(bitMap)).to.be.undefined; + }); + }); + describe('writeWorkspaceRoot and readWorkspaceRoot', () => { + it('should round-trip the root id with its version', () => { + const extensions = ExtensionDataList.fromArray([]); + writeWorkspaceRoot(extensions, rootId); + expect(readWorkspaceRoot(extensions)?.toString()).to.equal('my-scope/my-root@0.0.7'); + }); + it('should replace an existing pointer rather than add a second entry', () => { + const extensions = ExtensionDataList.fromArray([]); + writeWorkspaceRoot(extensions, rootId); + writeWorkspaceRoot(extensions, rootId.changeVersion('0.0.8')); + expect(extensions).to.have.lengthOf(1); + expect(readWorkspaceRoot(extensions)?.version).to.equal('0.0.8'); + }); + it('should keep a root that was never snapped without a version', () => { + const extensions = ExtensionDataList.fromArray([]); + writeWorkspaceRoot(extensions, ComponentID.fromString('my-scope/my-root')); + expect(readWorkspaceRoot(extensions)?.hasVersion()).to.be.false; + }); + it('should return undefined for a component with no pointer', () => { + expect(readWorkspaceRoot(ExtensionDataList.fromArray([]))).to.be.undefined; + }); + }); +}); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts new file mode 100644 index 000000000000..62bce759142a --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -0,0 +1,43 @@ +import { ComponentID } from '@teambit/component-id'; +import type { BitMap, ComponentMap } from '@teambit/legacy.bit-map'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import type { ExtensionDataList } from '@teambit/legacy.extension-data'; +import { ExtensionDataEntry } from '@teambit/legacy.extension-data'; +import { WorkspaceRootAspect } from './workspace-root.aspect'; + +/** + * the aspect data every member of a workspace-root component carries once snapped: the root of the + * workspace it was snapped in, at the version the root had at that moment. the version is left out + * only when the root was never snapped. + * + * it is data, not config, on purpose: a Version's hash covers the extensions' config only, so the + * pointer never makes a component modified, and the root moving on does not touch its members. + */ +export type WorkspaceRootData = { + /** e.g. "my-org.my-scope/my-root@0.0.7" */ + root: string; +}; + +/** + * the entry of the component that owns the workspace root (rootDir "."), if the workspace has one. + */ +export function findWorkspaceRootMap(bitMap: BitMap): ComponentMap | undefined { + return bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); +} + +export function readWorkspaceRoot(extensions: ExtensionDataList): ComponentID | undefined { + const root = extensions.findCoreExtension(WorkspaceRootAspect.id)?.data?.root; + return root ? ComponentID.fromString(root) : undefined; +} + +export function writeWorkspaceRoot(extensions: ExtensionDataList, rootId: ComponentID): void { + const data: WorkspaceRootData = { root: rootId.toString() }; + const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); + if (existing) { + existing.data = data; + return; + } + // core aspects are keyed by name in the extensions list, the same way the component loader adds + // aspect data (see WorkspaceComponentLoader.getDataEntry) + extensions.push(new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, data)); +} diff --git a/scopes/workspace/workspace-root/workspace-root.aspect.ts b/scopes/workspace/workspace-root/workspace-root.aspect.ts new file mode 100644 index 000000000000..d33d2c829624 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.aspect.ts @@ -0,0 +1,7 @@ +import { Aspect } from '@teambit/harmony'; + +export const WorkspaceRootAspect = Aspect.create({ + id: 'teambit.workspace/workspace-root', +}); + +export default WorkspaceRootAspect; diff --git a/scopes/workspace/workspace-root/workspace-root.docs.mdx b/scopes/workspace/workspace-root/workspace-root.docs.mdx new file mode 100644 index 000000000000..0181d7ce1c50 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.docs.mdx @@ -0,0 +1,28 @@ +--- +description: The component that owns the workspace root, and the record its members keep of it +labels: ['core aspect', 'workspace', 'workspace-root'] +--- + +## Overview + +A workspace-root component is tracked at the workspace root (`rootDir: "."`). It versions the +workspace's own files, such as `workspace.jsonc`, `.bitmap`, the lockfile and repo-level scripts and +configs, and a git-free workspace is restored from it. + +This aspect tells the root apart in a workspace and records, on every member the workspace snaps, +which root it was snapped in and at which version: + +```json +"teambit.workspace/workspace-root": { + "data": { "root": "my-org.my-scope/my-root@0.0.7" } +} +``` + +The record is aspect data, not config, so it never makes a component modified and the root moving on +does not touch its members. Its consumers are tools that need the root's files a version was made +with, such as a CI building the component or a workspace clone. + +## Terminology + +"Workspace-root component", because "root component" is taken by the dependency-resolver's +`rootComponents`, and "workspace component" means any component loaded from a workspace. diff --git a/scopes/workspace/workspace-root/workspace-root.fragment.ts b/scopes/workspace/workspace-root/workspace-root.fragment.ts new file mode 100644 index 000000000000..badc11061986 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.fragment.ts @@ -0,0 +1,22 @@ +import type { Component, ShowFragment } from '@teambit/component'; +import type { WorkspaceRootMain } from './workspace-root.main.runtime'; + +export class WorkspaceRootFragment implements ShowFragment { + constructor(private workspaceRoot: WorkspaceRootMain) {} + + title = 'workspace root'; + + async renderRow(component: Component) { + return { + title: this.title, + content: this.workspaceRoot.getRootOf(component)?.toString() ?? '', + }; + } + + async json(component: Component) { + return { + title: this.title, + json: this.workspaceRoot.getRootOf(component)?.toString(), + }; + } +} diff --git a/scopes/workspace/workspace-root/workspace-root.main.runtime.ts b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts new file mode 100644 index 000000000000..1dc5b41552f2 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts @@ -0,0 +1,59 @@ +import { MainRuntime } from '@teambit/cli'; +import type { Component, ComponentMain } from '@teambit/component'; +import { ComponentAspect } from '@teambit/component'; +import type { ComponentID } from '@teambit/component-id'; +import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; +import type { Workspace } from '@teambit/workspace'; +import { WorkspaceAspect } from '@teambit/workspace'; +import { WorkspaceRootAspect } from './workspace-root.aspect'; +import { WorkspaceRootFragment } from './workspace-root.fragment'; +import { findWorkspaceRootMap, readWorkspaceRoot } from './workspace-root-data'; + +/** + * the workspace-root component is the one tracked at the workspace root (rootDir "."). it versions + * the workspace's own files - workspace.jsonc, .bitmap, the lockfile, repo scripts and configs - and + * a git-free workspace is restored from it. this aspect is the home of that concept: telling the root + * apart in a workspace, and the record every member carries of the root it was snapped in. + * + * "workspace-root component" rather than "root component", which is the dependency-resolver's + * rootComponents, or "workspace component", which is every component loaded from a workspace. + */ +export class WorkspaceRootMain { + constructor(private workspace?: Workspace) {} + + /** + * the id of the component that owns the workspace root, if the workspace has one. + */ + getRootComponentId(): ComponentID | undefined { + if (!this.workspace) return undefined; + return findWorkspaceRootMap(this.workspace.consumer.bitMap)?.id; + } + + isWorkspaceRoot(id: ComponentID): boolean { + const rootId = this.getRootComponentId(); + return Boolean(rootId?.isEqualWithoutVersion(id)); + } + + /** + * the workspace-root component a component was snapped in, at the version the root had then. the + * snap records it as aspect data, so it is available wherever the component is, a bare scope + * included. it is the way to know which root files (lockfile, tsconfig, scripts) a version was + * made with. a component snapped in a workspace without a root component has none. + */ + getRootOf(component: Component): ComponentID | undefined { + const consumerComponent = component.state._consumer as ConsumerComponent; + return readWorkspaceRoot(consumerComponent.extensions); + } + + static runtime = MainRuntime; + + static dependencies = [WorkspaceAspect, ComponentAspect]; + + static async provider([workspace, component]: [Workspace | undefined, ComponentMain]) { + const workspaceRoot = new WorkspaceRootMain(workspace); + component.registerShowFragments([new WorkspaceRootFragment(workspaceRoot)]); + return workspaceRoot; + } +} + +WorkspaceRootAspect.addRuntime(WorkspaceRootMain); From e3fd2df6b1ea0d98df77e2629e7f1a35d8314653 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 09:47:31 -0400 Subject: [PATCH 041/102] feat(workspace-root): snap a new or modified root along with its members, mark the root in its aspect data --- components/legacy/bit-map/bit-map.spec.ts | 43 +---------------- components/legacy/bit-map/bit-map.ts | 22 --------- components/legacy/bit-map/index.ts | 1 - e2e/harmony/add-harmony.e2e.ts | 46 +++++++++++++++--- .../component-writer.main.runtime.ts | 12 ++--- scopes/component/snapping/snap-cmd.ts | 16 ++++++- .../snapping/snapping.main.runtime.ts | 47 ++++++++++++++++--- scopes/component/snapping/tag-cmd.ts | 8 ++++ scopes/component/snapping/version-maker.ts | 17 ++++++- scopes/workspace/workspace-root/index.ts | 7 ++- .../workspace-root-data.spec.ts | 25 +++++++++- .../workspace-root/workspace-root-data.ts | 36 ++++++++++---- .../workspace-root/workspace-root.docs.mdx | 20 ++++++-- .../workspace-root/workspace-root.fragment.ts | 8 +++- .../workspace-root.main.runtime.ts | 35 ++++++++++---- 15 files changed, 231 insertions(+), 112 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 1a9920f4b5d0..5e4e4b355202 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -5,12 +5,7 @@ import * as path from 'path'; import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; import { logger } from '@teambit/legacy.logger'; -import { - BitMap, - fileContentsForVersioning, - isWorkspaceMapOwnedBy, - normalizeBitmapContentForVersioning, -} from './bit-map'; +import { BitMap, fileContentsForVersioning, normalizeBitmapContentForVersioning } from './bit-map'; import { WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -237,42 +232,6 @@ describe('BitMap', function () { expect(fileContentsForVersioning(nestedMap, '.bitmap', rawBitmap)).to.equal(rawBitmap); }); }); - describe('isWorkspaceMapOwnedBy', () => { - const rawMap = JSON.stringify({ - '$schema-version': '17.0.0', - 'ws-root': { scope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }, - comp1: { scope: 'my-scope', version: '', mainFile: 'index.ts', rootDir: 'packages/comp1' }, - }); - const id = (name: string, scope = 'my-scope') => ComponentID.fromObject({ name }, scope); - it('should recognize the component the map lists as the owner of the workspace root', () => { - expect(isWorkspaceMapOwnedBy(rawMap, id('ws-root'))).to.be.true; - }); - it('should resolve the scope of an entry snapped before its export from its defaultScope', () => { - const entry = { scope: '', defaultScope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }; - const rawMapBeforeExport = JSON.stringify({ 'ws-root': entry }); - expect(isWorkspaceMapOwnedBy(rawMapBeforeExport, id('ws-root'))).to.be.true; - expect(isWorkspaceMapOwnedBy(rawMapBeforeExport, id('ws-root', 'other-scope'))).to.be.false; - const rawMapWithoutScope = JSON.stringify({ 'ws-root': { ...entry, defaultScope: undefined } }); - expect(isWorkspaceMapOwnedBy(rawMapWithoutScope, id('ws-root'))).to.be.false; - }); - it('should reject the same name from another scope', () => { - expect(isWorkspaceMapOwnedBy(rawMap, id('ws-root', 'other-scope'))).to.be.false; - }); - it('should find the owner under its scope-qualified key when another scope took the bare name', () => { - // the map keys duplicate names by "scope/name". the bare key is then the other scope's component - const rawMapWithDuplicateName = JSON.stringify({ - 'ws-root': { scope: 'other-scope', version: '', mainFile: 'index.ts', rootDir: 'packages/other-root' }, - 'my-scope/ws-root': { scope: 'my-scope', version: '', mainFile: 'README.md', rootDir: '.' }, - }); - expect(isWorkspaceMapOwnedBy(rawMapWithDuplicateName, id('ws-root'))).to.be.true; - expect(isWorkspaceMapOwnedBy(rawMapWithDuplicateName, id('ws-root', 'other-scope'))).to.be.false; - }); - it('should reject a component the map lists elsewhere, or not at all, and a map it cannot parse', () => { - expect(isWorkspaceMapOwnedBy(rawMap, id('comp1'))).to.be.false; - expect(isWorkspaceMapOwnedBy(rawMap, id('other'))).to.be.false; - expect(isWorkspaceMapOwnedBy('not a map', id('ws-root'))).to.be.false; - }); - }); describe('loading a workspace that has no .bitmap yet', () => { it('should keep the ignore options, they apply to the scans of the components added next', async () => { const bitMap = await BitMap.load(__dirname, '', ['*.bak'], true); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 1d99d9a71720..d5d6bbb1b59f 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1112,28 +1112,6 @@ type OutputFileParams = { * collapse onto the default scope, and same-named components from different scopes would overwrite * each other. */ -/** - * whether a versioned map is the workspace map of the given component: it lists the component itself - * as the owner of the workspace root. this is what tells a workspace-root component from an ordinary - * one that happens to carry a file of that name, when it is written to ".". - */ -export function isWorkspaceMapOwnedBy(rawContent: Buffer | string, id: ComponentID): boolean { - let parsed: Record | undefined; - try { - parsed = json.parse(rawContent.toString(), undefined, true) as Record | undefined; - } catch { - return false; - } - // the map keys an entry by name, and by "scope/name" when another scope has the same name. the - // qualified key is checked first: with both present, the bare one is the other scope's component. - const entry = parsed?.[id.toStringWithoutVersion()] ?? parsed?.[id.fullName]; - if (!entry || typeof entry !== 'object' || entry.rootDir !== WORKSPACE_ROOT_DIR) return false; - // an entry snapped before its export carries its scope as defaultScope. either way it has to be the - // scope the component comes from - an empty one is not a wildcard. - const entryScope = entry.scope || entry.defaultScope; - return Boolean(entryScope) && entryScope === id.scope; -} - export function normalizeBitmapContentForVersioning(rawContent: string): string { const parsed = json.parse(rawContent, undefined, true) as Record | undefined; if (!parsed) return rawContent; diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 3add5ba0a4f8..1221c98fbc34 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -7,7 +7,6 @@ export { LANE_KEY, normalizeBitmapContentForVersioning, fileContentsForVersioning, - isWorkspaceMapOwnedBy, } from './bit-map'; export { MissingBitMapComponent, MissingMainFile, InvalidBitMap } from './exceptions'; export { diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index e29aee588843..a3b0b3af0bc6 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -131,12 +131,17 @@ describe('add command on Harmony', function () { .extensions.find((ext) => ext.name === 'teambit.workspace/workspace-root')?.data; expect(rootData).to.deep.equal({ root: `${helper.scopes.remote}/ws-root@${rootHead}` }); }); - it('should not record it on the root component itself', () => { - const extensionNames = helper.command.catComponent('ws-root@latest').extensions.map((ext) => ext.name); - expect(extensionNames).to.not.include('teambit.workspace/workspace-root'); + it('should mark the root component itself as such in its aspect data', () => { + // the marker is what tells a root from the model alone. an import onto "." relies on it. + const rootData = helper.command + .catComponent('ws-root@latest') + .extensions.find((ext) => ext.name === 'teambit.workspace/workspace-root')?.data; + expect(rootData).to.deep.equal({ isRoot: true }); }); describe('adding a new component inside the workspace root', () => { + let rootHeadBefore: string; before(() => { + rootHeadBefore = helper.command.getHead('ws-root'); helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); helper.command.addComponent('comp2', { i: 'comp2' }); }); @@ -147,9 +152,38 @@ describe('add command on Harmony', function () { it('should mark the root component as modified, because the map changed', () => { expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(1); }); - it('should converge again after snapping the map change', () => { - helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); - expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(0); + describe('snapping only the new component', () => { + let output: string; + before(() => { + output = helper.command.snapComponentWithoutBuild('comp2', '--ignore-issues "*"'); + }); + it('should snap the modified root along, and say so', () => { + // otherwise the new component would point at a root version whose map does not list it + expect(helper.command.getHead('ws-root')).to.not.equal(rootHeadBefore); + expect(output).to.have.string('is the workspace-root component'); + }); + it('should record the root on the new component at the version it got in that snap', () => { + const rootData = helper.command + .catComponent('comp2@latest') + .extensions.find((ext) => ext.name === 'teambit.workspace/workspace-root')?.data; + const rootHead = helper.command.getHead('ws-root'); + expect(rootData).to.deep.equal({ root: `${helper.scopes.remote}/ws-root@${rootHead}` }); + }); + it('should converge again', () => { + expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(0); + }); + }); + }); + describe('tagging a member with an explicit version while the root is modified', () => { + before(() => { + helper.fs.outputFile('README.md', '# workspace root, edited\n'); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1 v2";\n'); + helper.command.tagWithoutBuild('comp1', '--ver 1.0.0 --ignore-issues "*"'); + }); + it('should give the root a patch bump of its own rather than the version meant for the member', () => { + const bitMap = helper.bitMap.read(); + expect(bitMap.comp1.version).to.equal('1.0.0'); + expect(bitMap['ws-root'].version).to.equal('0.0.1'); }); }); }); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index ea1d04c86e33..3c101b7885d1 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -19,7 +19,8 @@ import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { isDir, isDirEmptySync, pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ComponentMap } from '@teambit/legacy.bit-map'; -import { isWorkspaceMapFile, isWorkspaceMapOwnedBy, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { isWorkspaceRootComponent } from '@teambit/workspace-root'; import { COMPONENT_CONFIG_FILE_NAME, WORKSPACE_JSONC } from '@teambit/legacy.constants'; import { DataToPersist } from '@teambit/component.sources'; import type { ConfigMergerMain, WorkspaceConfigUpdateResult } from '@teambit/config-merger'; @@ -364,13 +365,12 @@ to move all component files to a different directory, run bit remove and then bi /** * only a workspace-root component may be written to ".". any other component written there would own * every unclaimed file in the workspace from the next scan on, and drop out of install and link. a - * workspace-root component is told by the workspace map it versions, which lists the component - * itself as the owner of the root (see isWorkspaceMapOwnedBy) - a file of that name alone is not - * enough, a component snapped before maps were excluded from component scans may carry one. + * workspace-root component is told by the marker its versions carry (see WorkspaceRootData) - a + * .bitmap among its files is not enough, a component snapped before maps were excluded from + * component scans may carry one. */ private throwForNonWorkspaceRootComponent(component: ConsumerComponent) { - const mapFile = component.files.find((file) => isWorkspaceMapFile(pathNormalizeToLinux(file.relative))); - if (mapFile && isWorkspaceMapOwnedBy(mapFile.contents, component.id)) return; + if (isWorkspaceRootComponent(component.extensions)) return; throw new BitError( `unable to import "${component.id.toString()}" to the workspace root, it is not a workspace-root component. use --path to write it to a directory` ); diff --git a/scopes/component/snapping/snap-cmd.ts b/scopes/component/snapping/snap-cmd.ts index 9d67aed9418b..ceae8fb49087 100644 --- a/scopes/component/snapping/snap-cmd.ts +++ b/scopes/component/snapping/snap-cmd.ts @@ -205,6 +205,12 @@ export function snapResultReport(results: SnapResults): string | Report { const warningsSection = warnings && warnings.length ? warnings.map((w) => `${warnSymbol} ${chalk.yellow(w)}`).join('\n') : ''; + const workspaceRootHint = results.autoAddedWorkspaceRoot + ? formatHint( + `(${compInBold(results.autoAddedWorkspaceRoot)} is the workspace-root component. it was new or modified, so it was snapped along: the other components record the root version they were snapped with)` + ) + : ''; + const laneStr = laneName ? ` on "${laneName}" lane` : ''; const summary = formatSuccessSummary(`${totalCount} component(s) snapped${laneStr}`); const snapExplanation = formatHint( @@ -232,6 +238,7 @@ export function snapResultReport(results: SnapResults): string | Report { const data = joinSections([ newSection, changedSection, + workspaceRootHint, autoSnapSection, removedSection, warningsSection, @@ -245,7 +252,14 @@ export function snapResultReport(results: SnapResults): string | Report { // Build detailed output (with full auto-snapped listing) const { newSection: newDetailed, changedSection: changedDetailed } = buildSections(formatCompDetailed); const detailedFooter = [summary, snapExplanation].filter(Boolean).join('\n'); - const details = joinSections([newDetailed, changedDetailed, removedSection, warningsSection, detailedFooter]); + const details = joinSections([ + newDetailed, + changedDetailed, + workspaceRootHint, + removedSection, + warningsSection, + detailedFooter, + ]); return { data, code: 0, details }; } diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 83484d473435..b0a4bc62fa3d 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -16,6 +16,7 @@ import type { ReleaseType } from 'semver'; import semver from 'semver'; import { compact, difference, uniq } from 'lodash'; import { ComponentID, ComponentIdList } from '@teambit/component-id'; +import { findWorkspaceRootMap } from '@teambit/workspace-root'; import type { BuildStatus } from '@teambit/legacy.constants'; import { Extensions, LATEST } from '@teambit/legacy.constants'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -136,6 +137,11 @@ export type BasicTagResults = { newComponents: ComponentIdList; removedComponents?: ComponentIdList; totalComponentsCount?: number; // total count of all components tagged/snapped (including auto-tagged) + /** + * the workspace-root component that joined the batch because it was new or modified, at its new + * version. see getWorkspaceRootToTagAlong. + */ + autoAddedWorkspaceRoot?: ComponentID; }; export type FileData = { path: string; content: string; delete?: boolean }; @@ -261,9 +267,12 @@ export class SnappingMain { if (!bitIds.length) return null; const compIds = ComponentIdList.fromArray(bitIds); + // --persist tags what the soft-tag recorded, and the soft-tag has already brought the root along + const autoAddedWorkspaceRoot = persist ? undefined : await this.getWorkspaceRootToTagAlong(compIds); + const idsToTag = autoAddedWorkspaceRoot ? ComponentIdList.fromArray([...compIds, autoAddedWorkspaceRoot]) : compIds; - this.logger.debug(`tagging the following components: ${compIds.toString()}`); - const components = await this.loadComponentsForTagOrSnap(compIds, !soft); + this.logger.debug(`tagging the following components: ${idsToTag.toString()}`); + const components = await this.loadComponentsForTagOrSnap(idsToTag, !soft); await this.throwForVariousIssues(components, ignoreIssues); const params = { @@ -293,6 +302,7 @@ export class SnappingMain { overrideHead, loose, ignoreIssues, + autoAddedWorkspaceRoot, }; const { taggedComponents, @@ -301,7 +311,7 @@ export class SnappingMain { stagedConfig, removedComponents, totalComponentsCount, - } = await this.makeVersion(compIds, components, params); + } = await this.makeVersion(idsToTag, components, params); const tagResults = { taggedComponents, @@ -312,6 +322,7 @@ export class SnappingMain { newComponents, removedComponents, totalComponentsCount, + autoAddedWorkspaceRoot: findVersioned(taggedComponents, autoAddedWorkspaceRoot), }; await consumer.onDestroy(`tag (message: ${message || 'N/A'})`); @@ -617,8 +628,10 @@ export class SnappingMain { const self = this; const ids = legacyBitIds || (await getIdsToSnap()); if (!ids) return null; - this.logger.debug(`snapping the following components: ${ids.toString()}`); - const components = await this.loadComponentsForTagOrSnap(ids); + const autoAddedWorkspaceRoot = await this.getWorkspaceRootToTagAlong(ids); + const idsToSnap = autoAddedWorkspaceRoot ? ComponentIdList.fromArray([...ids, autoAddedWorkspaceRoot]) : ids; + this.logger.debug(`snapping the following components: ${idsToSnap.toString()}`); + const components = await this.loadComponentsForTagOrSnap(idsToSnap); await this.throwForVariousIssues(components, ignoreIssues); const makeVersionParams = { editor, @@ -640,9 +653,10 @@ export class SnappingMain { detachHead, loose, ignoreIssues, + autoAddedWorkspaceRoot, }; const { taggedComponents, autoTaggedResults, stagedConfig, removedComponents, totalComponentsCount } = - await this.makeVersion(ids, components, makeVersionParams); + await this.makeVersion(idsToSnap, components, makeVersionParams); const snapResults: Partial = { snappedComponents: taggedComponents, @@ -650,6 +664,7 @@ export class SnappingMain { newComponents, removedComponents, totalComponentsCount, + autoAddedWorkspaceRoot: findVersioned(taggedComponents, autoAddedWorkspaceRoot), }; const currentLane = consumer.getCurrentLaneId(); @@ -1398,6 +1413,19 @@ another option, in case this dependency is not in main yet is to remove all refe component.config.extensions.push(extension); } + /** + * a new or modified workspace-root component joins every tag and snap of its members. they record + * the root at the version that has their files (see VersionMaker.recordWorkspaceRoot), and a build + * of a member elsewhere needs the workspace.jsonc, lockfile and configs it was made with. a root + * that is up to date is recorded at its current version and stays out of the batch. + */ + private async getWorkspaceRootToTagAlong(ids: ComponentIdList): Promise { + const rootId = findWorkspaceRootMap(this.workspace.consumer.bitMap)?.id; + if (!rootId || ids.hasWithoutVersion(rootId)) return undefined; + const status = await this.workspace.getComponentStatusById(rootId); + return status.newlyCreated || status.modified ? rootId.changeVersion(undefined) : undefined; + } + private async getTagPendingComponentsIds(includeUnmodified = false) { const ids = includeUnmodified ? await this.workspace.listPotentialTagIds() @@ -1561,6 +1589,13 @@ another option, in case this dependency is not in main yet is to remove all refe } } +/** + * the id of the given component as it was just versioned, for the results. + */ +function findVersioned(components: ConsumerComponent[], id?: ComponentID): ComponentID | undefined { + return id && components.find((component) => component.id.isEqualWithoutVersion(id))?.id; +} + SnappingAspect.addRuntime(SnappingMain); export default SnappingMain; diff --git a/scopes/component/snapping/tag-cmd.ts b/scopes/component/snapping/tag-cmd.ts index fa36bd627435..32a650efdd61 100644 --- a/scopes/component/snapping/tag-cmd.ts +++ b/scopes/component/snapping/tag-cmd.ts @@ -368,6 +368,12 @@ export function tagResultReport(results: TagResults): string | Report { const warningsSection = warnings && warnings.length ? warnings.map((w) => `${warnSymbol} ${chalk.yellow(w)}`).join('\n') : ''; + const workspaceRootHint = results.autoAddedWorkspaceRoot + ? formatHint( + `(${compInBold(results.autoAddedWorkspaceRoot)} is the workspace-root component. it was new or modified, so it was tagged along: the other components record the root version they were tagged with)` + ) + : ''; + const summaryMsg = `${totalCount} component(s) ${results.isSoftTag ? 'soft-' : ''}tagged${exportedIds ? ' and exported' : ''}`; const summary = formatSuccessSummary(summaryMsg); @@ -408,6 +414,7 @@ export function tagResultReport(results: TagResults): string | Report { const data = joinSections([ newSection, changedSection, + workspaceRootHint, autoTagSection, removedSection, publishSection, @@ -426,6 +433,7 @@ export function tagResultReport(results: TagResults): string | Report { const details = joinSections([ newDetailed, changedDetailed, + workspaceRootHint, removedSection, publishSection, exportedSection, diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index bff74400de6b..58b19dc2bf74 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -82,6 +82,11 @@ export type VersionMakerParams = { exitOnFirstFailedTask?: boolean; updateDependentsOnLane?: boolean; setHeadAsParent?: boolean; + /** + * the workspace-root component that joined the batch on its own, being new or modified. see + * SnappingMain.getWorkspaceRootToTagAlong. + */ + autoAddedWorkspaceRoot?: ComponentID; } & BasicTagParams; type ComputedVersion = { componentToTag: ConsumerComponent; version: string }; @@ -555,6 +560,11 @@ export class VersionMaker { } return soft ? 'patch' : modelComponent.getVersionToAdd('patch', undefined, incrementBy, preReleaseId); } + if (this.params.autoAddedWorkspaceRoot?.isEqualWithoutVersion(componentToTag.id)) { + // the root joined the batch on its own. a version given for the members - `--ver`, or on the + // id - is not meant for it, so it is bumped the way an auto-tagged dependent is. + return soft ? 'patch' : modelComponent.getVersionToAdd('patch', undefined, incrementBy, preReleaseId); + } const versionByEnteredId = this.getVersionByEnteredId(this.ids, componentToTag, modelComponent); return soft ? versionByEnteredId || exactVersion || (releaseType as string) @@ -756,8 +766,11 @@ export class VersionMaker { /** * every member of a workspace-root component records the root it is snapped in, at the version the - * root has after this batch: its new version when it is snapped along, otherwise the one in .bitmap - * (none when the root was never snapped). the root itself records nothing. see WorkspaceRootMain. + * root has after this batch: its new version when it is snapped along, otherwise the one in .bitmap. + * tag and snap bring a new or modified root into the batch (see SnappingMain.getWorkspaceRootToTagAlong), + * so the version is only missing on the paths that don't, such as a merge snap made while the root + * was never snapped. the root itself records nothing here, it carries the isRoot marker instead. + * see WorkspaceRootMain. */ private recordWorkspaceRoot() { if (!this.consumer) return; diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts index 8d78a954ad0d..a5b469012541 100644 --- a/scopes/workspace/workspace-root/index.ts +++ b/scopes/workspace/workspace-root/index.ts @@ -1,4 +1,9 @@ export { WorkspaceRootAspect, default } from './workspace-root.aspect'; export type { WorkspaceRootMain } from './workspace-root.main.runtime'; export type { WorkspaceRootData } from './workspace-root-data'; -export { findWorkspaceRootMap, readWorkspaceRoot, writeWorkspaceRoot } from './workspace-root-data'; +export { + findWorkspaceRootMap, + isWorkspaceRootComponent, + readWorkspaceRoot, + writeWorkspaceRoot, +} from './workspace-root-data'; diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts index 9dce9a9c3a07..cb7cd04befd5 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.spec.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -1,8 +1,14 @@ import { expect } from 'chai'; import { ComponentID } from '@teambit/component-id'; import type { BitMap } from '@teambit/legacy.bit-map'; -import { ExtensionDataList } from '@teambit/legacy.extension-data'; -import { findWorkspaceRootMap, readWorkspaceRoot, writeWorkspaceRoot } from './workspace-root-data'; +import { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; +import { + findWorkspaceRootMap, + isWorkspaceRootComponent, + readWorkspaceRoot, + writeWorkspaceRoot, +} from './workspace-root-data'; +import { WorkspaceRootAspect } from './workspace-root.aspect'; const rootId = ComponentID.fromString('my-scope/my-root@0.0.7'); @@ -24,6 +30,21 @@ describe('workspace-root data', () => { expect(findWorkspaceRootMap(bitMap)).to.be.undefined; }); }); + describe('isWorkspaceRootComponent', () => { + const withData = (data: Record) => + ExtensionDataList.fromArray([ + new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, data), + ]); + it('should recognize the marker the root carries', () => { + expect(isWorkspaceRootComponent(withData({ isRoot: true }))).to.be.true; + }); + it('should not take a member, which carries the pointer to its root, for a root', () => { + expect(isWorkspaceRootComponent(withData({ root: rootId.toString() }))).to.be.false; + }); + it('should be false for a component with no data of this aspect', () => { + expect(isWorkspaceRootComponent(ExtensionDataList.fromArray([]))).to.be.false; + }); + }); describe('writeWorkspaceRoot and readWorkspaceRoot', () => { it('should round-trip the root id with its version', () => { const extensions = ExtensionDataList.fromArray([]); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts index 62bce759142a..f221942d76f7 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -6,16 +6,23 @@ import { ExtensionDataEntry } from '@teambit/legacy.extension-data'; import { WorkspaceRootAspect } from './workspace-root.aspect'; /** - * the aspect data every member of a workspace-root component carries once snapped: the root of the - * workspace it was snapped in, at the version the root had at that moment. the version is left out - * only when the root was never snapped. - * - * it is data, not config, on purpose: a Version's hash covers the extensions' config only, so the - * pointer never makes a component modified, and the root moving on does not touch its members. + * the aspect data of a workspace-root component and of its members. it is data, not config, on + * purpose: a Version's hash covers the extensions' config only, so neither entry ever makes a + * component modified, and the root moving on does not touch its members. */ export type WorkspaceRootData = { - /** e.g. "my-org.my-scope/my-root@0.0.7" */ - root: string; + /** + * on the workspace-root component itself. set when it is loaded in a workspace and saved with + * every version, so the root is told from the model alone - by an import onto ".", a CI, a clone - + * and not by the files it happens to carry. + */ + isRoot?: boolean; + /** + * on a member: the root of the workspace it was snapped in, at the version the root had at that + * moment, e.g. "my-org.my-scope/my-root@0.0.7". tag and snap bring a new or modified root into + * their batch, so a member they made always has the version. + */ + root?: string; }; /** @@ -25,8 +32,19 @@ export function findWorkspaceRootMap(bitMap: BitMap): ComponentMap | undefined { return bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); } +function findData(extensions: ExtensionDataList): WorkspaceRootData | undefined { + return extensions.findCoreExtension(WorkspaceRootAspect.id)?.data; +} + +/** + * whether the component is a workspace-root component, by the marker its versions carry. + */ +export function isWorkspaceRootComponent(extensions: ExtensionDataList): boolean { + return Boolean(findData(extensions)?.isRoot); +} + export function readWorkspaceRoot(extensions: ExtensionDataList): ComponentID | undefined { - const root = extensions.findCoreExtension(WorkspaceRootAspect.id)?.data?.root; + const root = findData(extensions)?.root; return root ? ComponentID.fromString(root) : undefined; } diff --git a/scopes/workspace/workspace-root/workspace-root.docs.mdx b/scopes/workspace/workspace-root/workspace-root.docs.mdx index 0181d7ce1c50..0a32eaf8971e 100644 --- a/scopes/workspace/workspace-root/workspace-root.docs.mdx +++ b/scopes/workspace/workspace-root/workspace-root.docs.mdx @@ -9,8 +9,16 @@ A workspace-root component is tracked at the workspace root (`rootDir: "."`). It workspace's own files, such as `workspace.jsonc`, `.bitmap`, the lockfile and repo-level scripts and configs, and a git-free workspace is restored from it. -This aspect tells the root apart in a workspace and records, on every member the workspace snaps, -which root it was snapped in and at which version: +This aspect owns two pieces of data, both saved with every version. The root marks itself, so it is +told from the model alone rather than by the files it happens to carry: + +```json +"teambit.workspace/workspace-root": { + "data": { "isRoot": true } +} +``` + +Every member the workspace snaps records which root it was snapped in and at which version: ```json "teambit.workspace/workspace-root": { @@ -18,8 +26,12 @@ which root it was snapped in and at which version: } ``` -The record is aspect data, not config, so it never makes a component modified and the root moving on -does not touch its members. Its consumers are tools that need the root's files a version was made +A new or modified root joins every `bit tag` and `bit snap` of its members, so the recorded version +is always the one that has the files the member was made with. A root tagged along is bumped as a +patch, whatever version the members were given. + +Both are aspect data, not config, so they never make a component modified and the root moving on +does not touch its members. Their consumers are tools that need the root's files a version was made with, such as a CI building the component or a workspace clone. ## Terminology diff --git a/scopes/workspace/workspace-root/workspace-root.fragment.ts b/scopes/workspace/workspace-root/workspace-root.fragment.ts index badc11061986..9338563ad100 100644 --- a/scopes/workspace/workspace-root/workspace-root.fragment.ts +++ b/scopes/workspace/workspace-root/workspace-root.fragment.ts @@ -9,14 +9,18 @@ export class WorkspaceRootFragment implements ShowFragment { async renderRow(component: Component) { return { title: this.title, - content: this.workspaceRoot.getRootOf(component)?.toString() ?? '', + content: this.workspaceRoot.isWorkspaceRootComponent(component) + ? 'this component' + : (this.workspaceRoot.getRootOf(component)?.toString() ?? ''), }; } async json(component: Component) { + const isRoot = this.workspaceRoot.isWorkspaceRootComponent(component); + const root = this.workspaceRoot.getRootOf(component)?.toString(); return { title: this.title, - json: this.workspaceRoot.getRootOf(component)?.toString(), + json: isRoot ? { isRoot } : root ? { root } : undefined, }; } } diff --git a/scopes/workspace/workspace-root/workspace-root.main.runtime.ts b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts index 1dc5b41552f2..715b2c5cc9c0 100644 --- a/scopes/workspace/workspace-root/workspace-root.main.runtime.ts +++ b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts @@ -2,18 +2,21 @@ import { MainRuntime } from '@teambit/cli'; import type { Component, ComponentMain } from '@teambit/component'; import { ComponentAspect } from '@teambit/component'; import type { ComponentID } from '@teambit/component-id'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; import { WorkspaceRootAspect } from './workspace-root.aspect'; import { WorkspaceRootFragment } from './workspace-root.fragment'; -import { findWorkspaceRootMap, readWorkspaceRoot } from './workspace-root-data'; +import type { WorkspaceRootData } from './workspace-root-data'; +import { findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot } from './workspace-root-data'; /** * the workspace-root component is the one tracked at the workspace root (rootDir "."). it versions * the workspace's own files - workspace.jsonc, .bitmap, the lockfile, repo scripts and configs - and - * a git-free workspace is restored from it. this aspect is the home of that concept: telling the root - * apart in a workspace, and the record every member carries of the root it was snapped in. + * a git-free workspace is restored from it. this aspect is the home of that concept: the marker that + * tells a root apart wherever it is, and the record every member carries of the root it was snapped + * in. * * "workspace-root component" rather than "root component", which is the dependency-resolver's * rootComponents, or "workspace component", which is every component loaded from a workspace. @@ -29,9 +32,12 @@ export class WorkspaceRootMain { return findWorkspaceRootMap(this.workspace.consumer.bitMap)?.id; } - isWorkspaceRoot(id: ComponentID): boolean { - const rootId = this.getRootComponentId(); - return Boolean(rootId?.isEqualWithoutVersion(id)); + /** + * whether the component is a workspace-root component. read from its aspect data, so it works + * for a component loaded from a scope as well as from a workspace. + */ + isWorkspaceRootComponent(component: Component): boolean { + return isWorkspaceRootComponent(this.extensionsOf(component)); } /** @@ -41,8 +47,11 @@ export class WorkspaceRootMain { * made with. a component snapped in a workspace without a root component has none. */ getRootOf(component: Component): ComponentID | undefined { - const consumerComponent = component.state._consumer as ConsumerComponent; - return readWorkspaceRoot(consumerComponent.extensions); + return readWorkspaceRoot(this.extensionsOf(component)); + } + + private extensionsOf(component: Component) { + return (component.state._consumer as ConsumerComponent).extensions; } static runtime = MainRuntime; @@ -52,8 +61,18 @@ export class WorkspaceRootMain { static async provider([workspace, component]: [Workspace | undefined, ComponentMain]) { const workspaceRoot = new WorkspaceRootMain(workspace); component.registerShowFragments([new WorkspaceRootFragment(workspaceRoot)]); + workspace?.registerOnComponentLoad(markWorkspaceRoot); return workspaceRoot; } } +/** + * the root marks itself as such in its aspect data when loaded. the snap saves the data with the + * version, and every consumer of the model reads the marker rather than guessing from the files. + */ +async function markWorkspaceRoot(component: Component): Promise { + const consumerComponent = component.state._consumer as ConsumerComponent; + return consumerComponent.componentMap?.rootDir === WORKSPACE_ROOT_DIR ? { isRoot: true } : undefined; +} + WorkspaceRootAspect.addRuntime(WorkspaceRootMain); From f5f79167ff32242fc662950aff21a64307b83de2 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 09:58:28 -0400 Subject: [PATCH 042/102] fix(workspace-root): symlink guard for tracked roots, keep same-named deps on remove, normalize nextVersion --- components/legacy/bit-map/bit-map.spec.ts | 5 ++++ components/legacy/bit-map/bit-map.ts | 2 ++ e2e/harmony/add-harmony.e2e.ts | 25 ++++++++++++++++--- .../component-writer.main.runtime.ts | 10 +++++--- scopes/component/remove/remove-components.ts | 23 +++++++++++------ 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 5e4e4b355202..59fd6bcf1c7c 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -149,6 +149,7 @@ describe('BitMap', function () { mainFile: 'index.ts', rootDir: 'comp1', config: { 'teambit.envs/envs': { env: 'teambit.harmony/node' } }, + nextVersion: { version: 'patch', message: 'soft-tagged' }, }, '$schema-version': '17.0.0', }, @@ -167,6 +168,10 @@ describe('BitMap', function () { it('should drop the config, which a snap moves into the version and removes from the map', () => { expect(parsed.comp1).to.not.have.property('config'); }); + it('should drop the pending soft-tag, which persisting it clears from the map', () => { + // otherwise the root would capture it when tagged along and never converge after --persist + expect(parsed.comp1).to.not.have.property('nextVersion'); + }); it('should drop the lane, workspace state that export and lane switch change', () => { expect(normalized).to.not.have.string('_bit_lane'); }); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index d5d6bbb1b59f..5537351fb3e9 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1123,6 +1123,8 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string if (key === SCHEMA_FIELD || !entry || typeof entry !== 'object') return; if (entry.version !== undefined) entry.version = ''; delete entry.config; + // the pending soft-tag, which --persist turns into a version and clears from the map + delete entry.nextVersion; }); return formatBitMapFile(parsed); } diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index a3b0b3af0bc6..8807841b53c9 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -196,12 +196,17 @@ describe('add command on Harmony', function () { helper.fs.outputFile('untracked-by-bit.txt', 'not a component file\n'); helper.command.addComponent('.', { i: 'ws-root' }); // a dependency that happens to share the package name the root's id derives - helper.fs.outputFile( - path.join('node_modules', helper.general.getPackageNameByCompName('ws-root', false), 'index.js'), - '' - ); + const packageName = helper.general.getPackageNameByCompName('ws-root', false); + helper.fs.outputFile(path.join('node_modules', packageName, 'index.js'), ''); + helper.fs.outputFile('package.json', JSON.stringify({ dependencies: { [packageName]: '1.0.0' } })); helper.command.removeComponent('ws-root --silent'); }); + it('should not drop a package.json dependency that shares its derived package name', () => { + // the root was never a package, so its removal has nothing to clean from the manifest + const packageName = helper.general.getPackageNameByCompName('ws-root', false); + const packageJson = fs.readJsonSync(path.join(helper.scopes.localPath, 'package.json')); + expect(packageJson.dependencies).to.have.property(packageName); + }); it('should not delete the workspace', () => { // its rootDir is the workspace itself, so deleting it takes .bitmap, .bit, every nested // component and every unrelated file with it. @@ -327,6 +332,18 @@ describe('add command on Harmony', function () { expect(cmd).to.throw('due to local changes'); expect(helper.workspaceJsonc.read()['teambit.workspace/workspace'].name).to.equal('renamed'); }); + it('should refuse to write through a symlink in the way of a root file even with --override, as it does for --path .', () => { + // the destination is "." by the existing .bitmap entry here, not by an explicit --path. a checkout + // takes the same route. + const outside = path.join(helper.scopes.localPath, '..', `outside-${path.basename(helper.scopes.localPath)}`); + fs.mkdirSync(outside); + fs.removeSync(path.join(helper.scopes.localPath, 'docs')); + fs.symlinkSync(outside, path.join(helper.scopes.localPath, 'docs')); + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--override'); + expect(cmd).to.throw('symbolic link'); + expect(path.join(outside, 'guide.md')).to.not.be.a.path(); + fs.removeSync(outside); + }); }); describe('importing it onto the root of a fresh workspace that has its own files', () => { before(() => { diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 3c101b7885d1..0734443066c8 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -280,12 +280,14 @@ export class ComponentWriterMain { ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) || WORKSPACE_ROOT_DIR : this.consumer.composeRelativeComponentPath(component.id); - if (componentRootDir === WORKSPACE_ROOT_DIR) { + // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map + const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); + // a component already tracked at the root is written there whatever path was derived above, e.g. by + // a checkout or a re-import, so the guards of the root run for it as well + if (componentRootDir === WORKSPACE_ROOT_DIR || existingComponentMap?.rootDir === WORKSPACE_ROOT_DIR) { this.throwForNonWorkspaceRootComponent(component); this.throwForSymlinksInTheWay(component); } - // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map - const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. // the workspace root is never relocated (see relocateOccupiedDirs), so its own check runs either way. @@ -357,7 +359,7 @@ to move all component files to a different directory, run bit remove and then bi const stat = lstatIfExists(this.consumer.toAbsolutePath(pathInTheWay)); if (!stat?.isSymbolicLink()) return; throw new BitError( - `unable to import "${component.id.toString()}" to the workspace root, "${pathInTheWay}" is a symbolic link and the import would write through it` + `unable to write "${component.id.toString()}" to the workspace root, "${pathInTheWay}" is a symbolic link and the files would be written through it` ); }); } diff --git a/scopes/component/remove/remove-components.ts b/scopes/component/remove/remove-components.ts index 7d74bea79db6..127cff90ea0f 100644 --- a/scopes/component/remove/remove-components.ts +++ b/scopes/component/remove/remove-components.ts @@ -169,8 +169,10 @@ If you understand the risks and wish to proceed with the removal, please use the if (deleteFiles) await deleteComponentsFiles(consumer, idsToCleanFromWorkspace); if (!track) { const removedComponents = componentsToRemove.filter((c) => idsToCleanFromWorkspace.hasWithoutVersion(c.id)); - await consumer.packageJson.removeComponentsFromDependencies(removedComponents); - await removeComponentsFromNodeModules(consumer, removedComponents); + // the root is untracked only. it was never a package, see withoutWorkspaceRoot. + const packageComponents = withoutWorkspaceRoot(consumer, removedComponents); + await consumer.packageJson.removeComponentsFromDependencies(packageComponents); + await removeComponentsFromNodeModules(consumer, packageComponents); await consumer.cleanFromBitMap(idsToCleanFromWorkspace); await workspace.cleanFromConfig(idsToCleanFromWorkspace); await workspace.removeFromStagedConfig(idsToCleanFromWorkspace); @@ -185,13 +187,20 @@ If you understand the risks and wish to proceed with the removal, please use the ); } -export async function removeComponentsFromNodeModules(consumer: Consumer, components: ConsumerComponent[]) { - logger.debug(`removeComponentsFromNodeModules: ${components.map((c) => c.id.toString()).join(', ')}`); - // the workspace-root component is never linked (it is the workspace, not a package), so there is - // nothing of it to remove, and the path its id derives may belong to a dependency of the same name. - const linkedComponents = components.filter( +/** + * the workspace-root component is never installed nor linked - it is the workspace, not a package - so + * there is nothing of it to remove from the manifests or from node_modules, and the package name its + * id derives may belong to a dependency of the same name. + */ +function withoutWorkspaceRoot(consumer: Consumer, components: ConsumerComponent[]): ConsumerComponent[] { + return components.filter( (c) => consumer.bitMap.getComponentIfExist(c.id, { ignoreVersion: true })?.rootDir !== WORKSPACE_ROOT_DIR ); +} + +export async function removeComponentsFromNodeModules(consumer: Consumer, components: ConsumerComponent[]) { + logger.debug(`removeComponentsFromNodeModules: ${components.map((c) => c.id.toString()).join(', ')}`); + const linkedComponents = withoutWorkspaceRoot(consumer, components); const pathsToRemoveWithNulls = linkedComponents.map((c) => { return getNodeModulesPathOfComponent({ ...c, id: c.id }); }); From 1d6d001d045501cad11945343eaaed664963e61f Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 10:49:23 -0400 Subject: [PATCH 043/102] feat(workspace-root): bit clone, a workspace from its workspace-root component --- components/legacy/bit-map/bit-map.spec.ts | 46 ++- components/legacy/bit-map/bit-map.ts | 28 ++ components/legacy/bit-map/index.ts | 2 + contrib/claude-skill-bit-cli/CLI_REFERENCE.md | 7 + contrib/claude-skill-bit-cli/SKILL.md | 1 + e2e/harmony/add-harmony.e2e.ts | 110 +++++++- scopes/harmony/bit/load-bit.ts | 5 + .../cli-reference/cli-reference.docs.mdx | 2 +- .../harmony/cli-reference/cli-reference.json | 37 +++ .../harmony/cli-reference/cli-reference.mdx | 22 ++ .../host-initializer.main.runtime.ts | 8 +- scopes/workspace/workspace-root/clone.cmd.ts | 76 +++++ scopes/workspace/workspace-root/clone.ts | 262 ++++++++++++++++++ scopes/workspace/workspace-root/index.ts | 1 + .../workspace-root/workspace-root.docs.mdx | 19 ++ .../workspace-root.main.runtime.ts | 39 ++- 16 files changed, 651 insertions(+), 14 deletions(-) create mode 100644 scopes/workspace/workspace-root/clone.cmd.ts create mode 100644 scopes/workspace/workspace-root/clone.ts diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 59fd6bcf1c7c..6f5b1ed21857 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -5,7 +5,12 @@ import * as path from 'path'; import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; import { logger } from '@teambit/legacy.logger'; -import { BitMap, fileContentsForVersioning, normalizeBitmapContentForVersioning } from './bit-map'; +import { + BitMap, + fileContentsForVersioning, + normalizeBitmapContentForVersioning, + readVersionedBitmapEntries, +} from './bit-map'; import { WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; @@ -188,6 +193,45 @@ describe('BitMap', function () { expect(normalizeBitmapContentForVersioning(normalized)).to.equal(normalized); }); }); + describe('readVersionedBitmapEntries', () => { + const versionedBitmap = `/* THIS IS A BIT-AUTO-GENERATED FILE */ +${JSON.stringify( + { + 'my-scope/comp1': { name: 'comp1', scope: 'my-scope', version: '', mainFile: 'index.ts', rootDir: 'comp1' }, + comp2: { + name: 'comp2', + scope: '', + version: '', + defaultScope: 'my-org.demo', + mainFile: 'index.ts', + rootDir: 'comp2', + }, + 'ws-root': { name: 'ws-root', scope: 'my-scope', version: '', mainFile: 'workspace.jsonc', rootDir: '.' }, + '$schema-version': '17.0.0', + }, + null, + 4 +)}`; + let entries: ReturnType; + before(() => { + entries = readVersionedBitmapEntries(versionedBitmap); + }); + it('should list every component with its root-dir, the root component included', () => { + expect(entries.map((entry) => entry.rootDir)).to.have.members(['comp1', 'comp2', WORKSPACE_ROOT_DIR]); + }); + it('should give an exported component its full id, which is what its remote knows it by', () => { + const comp1 = entries.find((entry) => entry.rootDir === 'comp1'); + expect(comp1).to.deep.equal({ id: 'my-scope/comp1', rootDir: 'comp1' }); + }); + it('should give a component not exported yet the scope it is exported to', () => { + // a root versioned before the first export lists its members this way, and that export carries them all + const comp2 = entries.find((entry) => entry.rootDir === 'comp2'); + expect(comp2).to.deep.equal({ id: 'my-org.demo/comp2', rootDir: 'comp2' }); + }); + it('should return nothing for an empty map', () => { + expect(readVersionedBitmapEntries(JSON.stringify({ '$schema-version': '17.0.0' }))).to.deep.equal([]); + }); + }); describe('a rootDir with one owner', () => { const componentParams = { componentId: ComponentID.fromObject({ name: 'comp1' }, 'my-scope'), diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 5537351fb3e9..1b4f814f6718 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1129,6 +1129,34 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string return formatBitMapFile(parsed); } +export type VersionedBitmapEntry = { + /** + * "scope/name". the scope is the one the component was exported to or, for a component not + * exported when the root was versioned, its default scope: the export that follows carries both. + */ + id: string; + rootDir: PathLinuxRelative; +}; + +/** + * the components a versioned `.bitmap` lists (see normalizeBitmapContentForVersioning): who they are + * and where they live, the root component included. versions are not in there by design, so a + * workspace made from this list takes the heads. + */ +export function readVersionedBitmapEntries(rawContent: string): VersionedBitmapEntry[] { + const parsed = json.parse(rawContent, undefined, true) as Record | undefined; + if (!parsed) return []; + BitMap.removeNonComponentFields(parsed); + return Object.keys(parsed) + .filter((key) => parsed[key] && typeof parsed[key] === 'object') + .map((key) => { + const entry = parsed[key]; + const name = entry.name || key; + const scope = entry.scope || entry.defaultScope; + return { id: scope ? `${scope}/${name}` : name, rootDir: entry.rootDir }; + }); +} + /** * the contents of a component file as bit versions and compares them. only the workspace-root * component's `.bitmap` is transformed (see normalizeBitmapContentForVersioning); every other file is diff --git a/components/legacy/bit-map/index.ts b/components/legacy/bit-map/index.ts index 1221c98fbc34..761c7dec1168 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -6,6 +6,8 @@ export { SCHEMA_FIELD, LANE_KEY, normalizeBitmapContentForVersioning, + readVersionedBitmapEntries, + VersionedBitmapEntry, fileContentsForVersioning, } from './bit-map'; export { MissingBitMapComponent, MissingMainFile, InvalidBitMap } from './exceptions'; diff --git a/contrib/claude-skill-bit-cli/CLI_REFERENCE.md b/contrib/claude-skill-bit-cli/CLI_REFERENCE.md index 59d2d39d86a0..a68c99865960 100644 --- a/contrib/claude-skill-bit-cli/CLI_REFERENCE.md +++ b/contrib/claude-skill-bit-cli/CLI_REFERENCE.md @@ -170,6 +170,13 @@ remove cached data to resolve stale data issues clears various caches that Bit uses to improve performance. useful when experiencing stale data issues or unexpected behavior. this command removes: 1) components cache on the filesystem (mainly the dependencies graph and docs) 2) scope's index file, which maps the component-id:object-hash note: this cache has minimal impact on disk space. to free significant disk space, use "bit capsule delete --all" to remove build capsules. Flags: --remote +## bit clone [dir] + +create a workspace from its workspace-root component, with every component it lists + +the workspace-root component is the one tracked at a workspace root ("bit add ."). it versions the workspace's own files - workspace.jsonc, .bitmap, lockfile, configs - and this command makes a workspace out of it, the way "git clone" makes a working tree out of a repository: the root files land at the root, every component the root lists is imported into the directory it records, then the dependencies are installed. the components come at their heads on main, or on the lane given with --lane. a version on the root id pins the root files only. runs outside a workspace. the directory must be empty or not exist, and defaults to the component name. +Flags: --lane , --remote , --skip-dependency-installation + ## bit compile [component-names...] transpile component source files diff --git a/contrib/claude-skill-bit-cli/SKILL.md b/contrib/claude-skill-bit-cli/SKILL.md index 8ab24cfdd65e..0d07eb47fe54 100644 --- a/contrib/claude-skill-bit-cli/SKILL.md +++ b/contrib/claude-skill-bit-cli/SKILL.md @@ -110,6 +110,7 @@ rename - change a component name Workspace & Project Setup new - create a new Bit workspace from a template init [path] - initialize a Bit workspace in an existing project +clone [dir] - create a workspace from its workspace-root component, with every component it lists Testing & Quality artifacts - view and download build artifacts diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 8807841b53c9..784c4cdff80f 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -518,22 +518,120 @@ describe('add command on Harmony', function () { it('should track the package.json of the workspace root', () => { expect(helper.command.getComponentFiles('ws-root')).to.include('package.json'); }); - describe('restoring the workspace from the scope', () => { + describe('cloning the workspace from its root component', () => { + // bit clone runs outside a workspace and needs no bit init. the remote is registered globally, as + // there is no workspace to register it in yet, and the clone lands in the emptied dir. + let output: string; before(() => { helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); helper.command.export(); - helper.scopeHelper.reInitWorkspace(); - helper.scopeHelper.addRemoteScope(); - helper.command.importComponentWithoutInstall('ws-root', '--path .'); - helper.command.importComponentWithoutInstall('comp1', '--path comp1'); + helper.scopeHelper.cleanWorkspace(); + helper.scopeHelper.addRemoteScope(undefined, undefined, true); + output = helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root . -x`); }); - it('should write the manifests back, so the workspace can be installed and built', () => { + after(() => { + helper.scopeHelper.removeRemoteScope(undefined, true); + }); + it('should report the root version and the number of components', () => { + expect(output).to.have.string(`cloned ${helper.scopes.remote}/ws-root@`); + expect(output).to.have.string('with 1 component'); + }); + it('should write the root files, the manifests included, so the workspace can be installed and built', () => { expect(path.join(helper.scopes.localPath, 'package.json')) .to.be.a.file() .with.content('{ "name": "monorepo", "private": true }\n'); + expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# workspace root\n'); + }); + it('should take the workspace.jsonc of the root over the one the init generates', () => { + expect(helper.workspaceJsonc.read()['teambit.workspace/workspace'].trackAllFiles).to.be.true; + }); + it('should write every component the root lists into the directory it records', () => { + expect(helper.bitMap.read().comp1.rootDir).to.equal('comp1'); expect(path.join(helper.scopes.localPath, 'comp1/package.json')).to.be.a.file(); expect(path.join(helper.scopes.localPath, 'comp1/tsconfig.json')).to.be.a.file(); }); + it('should reproduce the state of the source workspace, whose root the export left modified', () => { + // the export set the scopes in .bitmap after the root was snapped, so the versioned map lists + // the members by their default scope. the clone resolves them by it, and the root is modified + // in the clone as it is at the source, until the next snap takes it along. + const status = helper.command.statusJson(); + expect(status.modifiedComponents).to.deep.equal([`${helper.scopes.remote}/ws-root`]); + expect(status.newComponents).to.have.lengthOf(0); + }); + it('should refuse to run inside a workspace', () => { + const cmd = () => helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root other -x`); + expect(cmd).to.throw('inside the workspace'); + }); + it('should refuse a directory that is not empty', () => { + // run from the parent of the workspaces, which is not a workspace + const cmd = () => + helper.command.runCmd( + `bit clone ${helper.scopes.remote}/ws-root ${helper.scopes.localPath} -x`, + helper.scopes.e2eDir + ); + expect(cmd).to.throw('not empty'); + }); + it('should default the directory to the component name', () => { + const clonePath = path.join(helper.scopes.e2eDir, 'ws-root'); + fs.removeSync(clonePath); + helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root -x`, helper.scopes.e2eDir); + expect(path.join(clonePath, 'workspace.jsonc')).to.be.a.file(); + expect(path.join(clonePath, 'comp1/index.js')).to.be.a.file(); + fs.removeSync(clonePath); + }); + it('should refuse a component that is not a workspace-root component, leaving no directory behind', () => { + const clonePath = path.join(helper.scopes.e2eDir, 'not-a-root'); + const cmd = () => + helper.command.runCmd(`bit clone ${helper.scopes.remote}/comp1 ${clonePath} -x`, helper.scopes.e2eDir); + expect(cmd).to.throw('not a workspace-root component'); + expect(clonePath).to.not.be.a.path(); + }); + }); + }); + describe('cloning a workspace as it is on a lane', () => { + let comp1MainHead: string; + let comp2MainHead: string; + let comp1LaneHead: string; + let rootLaneHead: string; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.command.addComponent('comp2', { i: 'comp2' }); + helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + helper.command.export(); + comp1MainHead = helper.command.getHead('comp1'); + comp2MainHead = helper.command.getHead('comp2'); + helper.command.createLane('dev'); + // comp1 changes on the lane. the export set the scopes in .bitmap, so the root is modified and + // joins the snap on the lane as well. comp2 stays as it is on main. + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1 v2";\n'); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + helper.command.export(); + comp1LaneHead = helper.command.getHeadOfLane('dev', 'comp1'); + rootLaneHead = helper.command.getHeadOfLane('dev', 'ws-root'); + helper.scopeHelper.cleanWorkspace(); + helper.scopeHelper.addRemoteScope(undefined, undefined, true); + helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root . --lane ${helper.scopes.remote}/dev -x`); + }); + after(() => { + helper.scopeHelper.removeRemoteScope(undefined, true); + }); + it('should come out on the lane', () => { + expect(helper.bitMap.read()._bit_lane.id).to.deep.equal({ name: 'dev', scope: helper.scopes.remote }); + }); + it('should write the components the lane has at their heads on the lane, the root included', () => { + expect(comp1LaneHead).to.not.equal(comp1MainHead); + expect(helper.bitMap.read().comp1.version).to.equal(comp1LaneHead); + expect(helper.bitMap.read()['ws-root'].version).to.equal(rootLaneHead); + }); + it('should write a component the lane does not have at its head on main', () => { + expect(helper.bitMap.read().comp2.version).to.equal(comp2MainHead); + }); + it('should come out clean, the root converging on the .bitmap the clone built', () => { + helper.command.expectStatusToBeClean(); }); }); }); diff --git a/scopes/harmony/bit/load-bit.ts b/scopes/harmony/bit/load-bit.ts index 554e16bcf05f..a713b24fac64 100644 --- a/scopes/harmony/bit/load-bit.ts +++ b/scopes/harmony/bit/load-bit.ts @@ -54,6 +54,8 @@ import type { EnvsMain } from '@teambit/envs'; import { EnvsAspect } from '@teambit/envs'; import type { GeneratorMain } from '@teambit/generator'; import { GeneratorAspect } from '@teambit/generator'; +import type { WorkspaceRootMain } from '@teambit/workspace-root'; +import { WorkspaceRootAspect } from '@teambit/workspace-root'; import { HostInitializerMain } from '@teambit/host-initializer'; async function loadLegacyConfig(config: any) { @@ -301,6 +303,9 @@ export async function loadBit(path = process.cwd(), additionalAspects?: Aspect[] restoreGlobalsFromSnapshot, isCoreAspect, }); + // `bit clone` loads bit for the workspace it creates. same reason as the generator: this aspect + // depends on workspace-root, so workspace-root cannot import it. + harmony.get(WorkspaceRootAspect.id).setLoadBit(loadBit); return harmony; } diff --git a/scopes/harmony/cli-reference/cli-reference.docs.mdx b/scopes/harmony/cli-reference/cli-reference.docs.mdx index fc69a88d6dac..6566a7ceb2c5 100644 --- a/scopes/harmony/cli-reference/cli-reference.docs.mdx +++ b/scopes/harmony/cli-reference/cli-reference.docs.mdx @@ -1,4 +1,4 @@ --- -description: 'Bit command synopses. Bit version: 2.2.47' +description: 'Bit command synopses. Bit version: 2.2.48' labels: ['cli', 'mdx', 'docs'] --- diff --git a/scopes/harmony/cli-reference/cli-reference.json b/scopes/harmony/cli-reference/cli-reference.json index 07a2d2744d99..617937fd3a56 100644 --- a/scopes/harmony/cli-reference/cli-reference.json +++ b/scopes/harmony/cli-reference/cli-reference.json @@ -6381,5 +6381,42 @@ "description": "component name, component id, component pattern, or relative directory path. use component pattern to select multiple components.\nwrap the pattern with quotes. use comma to separate patterns and \"!\" to exclude. e.g. \"ui/**, !ui/button\".\nuse '$' prefix to filter by states/attributes, e.g. '$deprecated', '$modified' or '$env:teambit.react/react'.\nuse `bit pattern --help` to understand patterns better and `bit pattern ` to validate the pattern." } ] + }, + { + "name": "clone [dir]", + "alias": "", + "options": [ + [ + "l", + "lane ", + "clone the workspace as it is on this lane (\"scope/name\"): it comes out on the lane, with the components at their heads there" + ], + [ + "", + "remote ", + "url of the scope hosting the components, when it is neither on bit.cloud nor in the global remotes" + ], + [ + "x", + "skip-dependency-installation", + "do not install the dependencies, and so do not compile, after the clone" + ] + ], + "description": "create a workspace from its workspace-root component, with every component it lists", + "extendedDescription": "the workspace-root component is the one tracked at a workspace root (\"bit add .\"). it versions the workspace's own files - workspace.jsonc, .bitmap, lockfile, configs - and this command makes a workspace out of it, the way \"git clone\" makes a working tree out of a repository: the root files land at the root, every component the root lists is imported into the directory it records, then the dependencies are installed.\nthe components come at their heads on main, or on the lane given with --lane. a version on the root id pins the root files only.\nruns outside a workspace. the directory must be empty or not exist, and defaults to the component name.", + "group": "workspace-setup", + "private": false, + "remoteOp": true, + "skipWorkspace": true, + "arguments": [ + { + "name": "component-id", + "description": "the workspace-root component, with its scope. a version pins the root files" + }, + { + "name": "dir", + "description": "where to create the workspace. defaults to the component name, must be empty or absent" + } + ] } ] \ No newline at end of file diff --git a/scopes/harmony/cli-reference/cli-reference.mdx b/scopes/harmony/cli-reference/cli-reference.mdx index b4854e3ee2c1..7b375932e0c5 100644 --- a/scopes/harmony/cli-reference/cli-reference.mdx +++ b/scopes/harmony/cli-reference/cli-reference.mdx @@ -502,6 +502,28 @@ note: this cache has minimal impact on disk space. to free significant disk spac --- +## clone + +**Description**: create a workspace from its workspace-root component, with every component it lists +the workspace-root component is the one tracked at a workspace root ("bit add ."). it versions the workspace's own files - workspace.jsonc, .bitmap, lockfile, configs - and this command makes a workspace out of it, the way "git clone" makes a working tree out of a repository: the root files land at the root, every component the root lists is imported into the directory it records, then the dependencies are installed. +the components come at their heads on main, or on the lane given with --lane. a version on the root id pins the root files only. +runs outside a workspace. the directory must be empty or not exist, and defaults to the component name. + +`bit clone [dir]` + +| **Arg** | **Description** | +| -------------- | :------------------------------------------------------------------------------------: | +| `component-id` | the workspace-root component, with its scope. a version pins the root files | +| `dir` | where to create the workspace. defaults to the component name, must be empty or absent | + +| **Option** | **Option alias** | **Description** | +| -------------------------------- | :--------------: | ---------------------------------------------------------------------------------------------------------------------------- | +| `--lane ` | `-l` | clone the workspace as it is on this lane ("scope/name"): it comes out on the lane, with the components at their heads there | +| `--remote ` | | url of the scope hosting the components, when it is neither on bit.cloud nor in the global remotes | +| `--skip-dependency-installation` | `-x` | do not install the dependencies, and so do not compile, after the clone | + +--- + ## compile **Description**: transpile component source files diff --git a/scopes/harmony/host-initializer/host-initializer.main.runtime.ts b/scopes/harmony/host-initializer/host-initializer.main.runtime.ts index 8f6501235672..ab557ec2fd53 100644 --- a/scopes/harmony/host-initializer/host-initializer.main.runtime.ts +++ b/scopes/harmony/host-initializer/host-initializer.main.runtime.ts @@ -65,7 +65,7 @@ export class HostInitializerMain { workspaceConfigProps: WorkspaceExtensionProps = {}, generator?: string, agent?: string, - options: { skipDefaultMcp?: boolean } = {} + options: { skipDefaultMcp?: boolean; skipAgentInstructions?: boolean } = {} ): Promise<{ created: boolean; consumer: Consumer; agentFileWritten?: string; mcpFileWritten?: string }> { const consumerInfo = await getWorkspaceInfo(absPath || process.cwd()); // if "bit init" was running without any flags, the user is probably trying to init a new workspace but wasn't aware @@ -122,7 +122,11 @@ export class HostInitializerMain { let agentFileWritten: string | undefined; let mcpFileWritten: string | undefined; if (created) { - agentFileWritten = await HostInitializerMain.writeAgentInstructions(consumerPath, agent); + // a clone skips both: its root files come from the component, and only what the component + // versions belongs at the root + if (!options.skipAgentInstructions) { + agentFileWritten = await HostInitializerMain.writeAgentInstructions(consumerPath, agent); + } // Keep `.mcp.json` in sync with the agent template, which tells the // agent that the workspace ships a Cloud MCP config. Skipped only when // the caller (interactive init) knows the user explicitly opted out. diff --git a/scopes/workspace/workspace-root/clone.cmd.ts b/scopes/workspace/workspace-root/clone.cmd.ts new file mode 100644 index 000000000000..43b1e0e32d4c --- /dev/null +++ b/scopes/workspace/workspace-root/clone.cmd.ts @@ -0,0 +1,76 @@ +import path from 'path'; +import type { Command, CommandOptions } from '@teambit/cli'; +import { formatHint, formatSuccessSummary, formatWarningSummary } from '@teambit/cli'; +import type { CloneResult } from './clone'; +import type { WorkspaceRootMain } from './workspace-root.main.runtime'; + +type CloneCmdOptions = { + lane?: string; + remote?: string; + skipDependencyInstallation?: boolean; +}; + +export class CloneCmd implements Command { + name = 'clone [dir]'; + description = 'create a workspace from its workspace-root component, with every component it lists'; + extendedDescription = `the workspace-root component is the one tracked at a workspace root ("bit add ."). it versions the workspace's own files - workspace.jsonc, .bitmap, lockfile, configs - and this command makes a workspace out of it, the way "git clone" makes a working tree out of a repository: the root files land at the root, every component the root lists is imported into the directory it records, then the dependencies are installed. +the components come at their heads on main, or on the lane given with --lane. a version on the root id pins the root files only. +runs outside a workspace. the directory must be empty or not exist, and defaults to the component name.`; + arguments = [ + { + name: 'component-id', + description: 'the workspace-root component, with its scope. a version pins the root files', + }, + { + name: 'dir', + description: 'where to create the workspace. defaults to the component name, must be empty or absent', + }, + ]; + group = 'workspace-setup'; + skipWorkspace = true; + remoteOp = true; + loader = true; + options = [ + [ + 'l', + 'lane ', + 'clone the workspace as it is on this lane ("scope/name"): it comes out on the lane, with the components at their heads there', + ], + [ + '', + 'remote ', + 'url of the scope hosting the components, when it is neither on bit.cloud nor in the global remotes', + ], + ['x', 'skip-dependency-installation', 'do not install the dependencies, and so do not compile, after the clone'], + ] as CommandOptions; + + constructor(private workspaceRoot: WorkspaceRootMain) {} + + async report([id, dir]: [string, string], options: CloneCmdOptions): Promise { + // the clone changes the cwd to the new workspace, so the path is relative to where the user ran it + const cwd = process.cwd(); + const result = await this.workspaceRoot.clone(id, dir, options); + return formatCloneResult(result, path.relative(cwd, result.workspacePath) || '.'); + } +} + +export function formatCloneResult(result: CloneResult, relativeDir: string): string { + const count = result.components.length; + const summary = formatSuccessSummary( + `cloned ${result.rootId.toString()} into "${relativeDir}" with ${count} component${count === 1 ? '' : 's'}` + ); + const lane = result.laneId ? formatHint(`(the workspace is on lane ${result.laneId.toString()})`) : ''; + const missingCount = result.missing.length; + const missing = missingCount + ? formatWarningSummary( + `${missingCount} component${missingCount === 1 ? '' : 's'} the root lists ${missingCount === 1 ? 'is' : 'are'} not on ${missingCount === 1 ? 'its' : 'their'} remote (never exported, or exported elsewhere), so the clone is without: ${result.missing.join(', ')}` + ) + : ''; + const installation = result.installationError + ? formatWarningSummary( + `the dependencies were not installed: ${result.installationError.message}\nrun "bit install" in the workspace to retry` + ) + : ''; + const next = relativeDir === '.' ? '' : formatHint(`cd ${relativeDir}`); + return [summary, lane, missing, installation, next].filter(Boolean).join('\n'); +} diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts new file mode 100644 index 000000000000..49ff32c67bdb --- /dev/null +++ b/scopes/workspace/workspace-root/clone.ts @@ -0,0 +1,262 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { BitError } from '@teambit/bit-error'; +import type { ComponentID } from '@teambit/component-id'; +import type { Harmony } from '@teambit/harmony'; +import { HostInitializerMain } from '@teambit/host-initializer'; +import type { ImporterMain } from '@teambit/importer'; +import { ImporterAspect } from '@teambit/importer'; +import type { InstallMain } from '@teambit/install'; +import { InstallAspect } from '@teambit/install'; +import type { LaneId } from '@teambit/lane-id'; +import type { LanesMain } from '@teambit/lanes'; +import { LanesAspect } from '@teambit/lanes'; +import type { VersionedBitmapEntry } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile, readVersionedBitmapEntries, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { ComponentNotFound } from '@teambit/legacy.scope'; +import { pathNormalizeToLinux } from '@teambit/legacy.utils'; +import type { ScopeMain } from '@teambit/scope'; +import { ScopeAspect } from '@teambit/scope'; +import { Remote } from '@teambit/scope.remotes'; +import type { Workspace } from '@teambit/workspace'; +import { WorkspaceAspect } from '@teambit/workspace'; +import { isWorkspaceRootComponent } from './workspace-root-data'; + +export type LoadBit = (path?: string) => Promise; + +export type CloneOptions = { + /** + * clone the workspace as it is on this lane, "scope/name". the workspace comes out on the lane, + * and the components at their heads there; a component the lane does not have comes from main. + */ + lane?: string; + /** + * url of the scope hosting the components, for a scope that is neither on bit.cloud nor in the + * global remotes. it is registered in the new workspace. + */ + remote?: string; + skipDependencyInstallation?: boolean; +}; + +export type CloneResult = { + /** the root, at the version the workspace was made from */ + rootId: ComponentID; + workspacePath: string; + /** the components the root lists, at the versions they were written with */ + components: ComponentID[]; + /** + * components the root lists that their remote does not have - never exported, or exported to + * another scope - so the clone is without them. + */ + missing: string[]; + laneId?: LaneId; + /** the clone is complete without the install; the user re-runs it in the workspace */ + installationError?: Error; +}; + +/** + * runs against the new workspace, so it works on the aspects loaded for it rather than on the ones + * of the process, which loaded without a workspace. + */ +class WorkspaceCloner { + private workspace: Workspace; + private importer: ImporterMain; + private lanes: LanesMain; + private scope: ScopeMain; + private install: InstallMain; + + constructor( + harmony: Harmony, + private workspacePath: string + ) { + this.workspace = harmony.get(WorkspaceAspect.id); + this.importer = harmony.get(ImporterAspect.id); + this.lanes = harmony.get(LanesAspect.id); + this.scope = harmony.get(ScopeAspect.id); + this.install = harmony.get(InstallAspect.id); + } + + async clone(rootId: ComponentID, options: CloneOptions): Promise { + if (options.remote) await this.addRemote(options.remote); + const laneId = options.lane ? await this.switchToLane(options.lane) : undefined; + const { versionedRootId, entries } = await this.fetchRoot(rootId); + await this.write(versionedRootId.toString(), WORKSPACE_ROOT_DIR); + const components: ComponentID[] = []; + const missing: string[] = []; + for (const entry of entries) { + if (entry.rootDir === WORKSPACE_ROOT_DIR) continue; + const written = await this.write(entry.id, entry.rootDir); + if (written) components.push(written); + else missing.push(entry.id); + } + const installationError = options.skipDependencyInstallation ? undefined : await this.installGracefully(); + return { + rootId: versionedRootId, + workspacePath: this.workspacePath, + components, + missing, + laneId, + installationError, + }; + } + + private async addRemote(url: string) { + const remote = new Remote(url); + remote.name = (await remote.scope()).name; + const scopeJson = this.scope.legacyScope.scopeJson; + scopeJson.addRemote(remote); + await scopeJson.write(); + } + + /** + * the workspace comes out on the lane with nothing written: restricted to the workspace's own + * components, a switch in an empty workspace has none to check out. it fetches the lane and the + * objects of its components on the way, and the imports that follow take the lane heads because + * the workspace is on it. + */ + private async switchToLane(lane: string): Promise { + if (!lane.includes('/')) { + throw new BitError( + `unable to clone from lane "${lane}", the lane must be given with its scope, e.g. "my-org.my-scope/${lane}"` + ); + } + await this.lanes.switchLanes(lane, { workspaceOnly: true, skipDependencyInstallation: true }); + const laneId = this.lanes.getCurrentLaneId(); + if (!laneId) throw new Error(`clone: the workspace is not on lane "${lane}" after switching to it`); + return laneId; + } + + /** + * brings the root's objects, settles its version - the one asked for, or its head on the lane or on + * main - and reads the components it lists from its versioned `.bitmap`. + */ + private async fetchRoot( + rootId: ComponentID + ): Promise<{ versionedRootId: ComponentID; entries: VersionedBitmapEntry[] }> { + const { importedIds } = await this.importer.import({ + ids: [rootId.toString()], + objectsOnly: true, + installNpmPackages: false, + writeConfigFiles: false, + }); + const versionedRootId = importedIds.find((id) => id.isEqualWithoutVersion(rootId)); + if (!versionedRootId) throw new BitError(`unable to clone, "${rootId.toString()}" was not imported`); + const rootComponent = await this.scope.legacyScope.getConsumerComponent(versionedRootId); + if (!isWorkspaceRootComponent(rootComponent.extensions)) { + throw new BitError( + `unable to clone "${versionedRootId.toString()}", it is not a workspace-root component. a workspace is cloned from the component tracked at its root, run "bit add ." there to create one` + ); + } + const bitmapFile = rootComponent.files.find((file) => isWorkspaceMapFile(pathNormalizeToLinux(file.relative))); + const entries = bitmapFile ? readVersionedBitmapEntries(bitmapFile.contents.toString()) : []; + return { versionedRootId, entries }; + } + + /** + * as `bit import --path ` does. one import per component, the path is the batch's. + * + * @returns undefined when the remote does not have the component. a root versioned before the first + * export lists its members by their default scope, and a member may have stayed behind. + */ + private async write(id: string, rootDir: string): Promise { + let importedIds: ComponentID[]; + try { + ({ importedIds } = await this.importer.import({ + ids: [id], + writeToPath: path.join(this.workspacePath, rootDir), + installNpmPackages: false, + writeConfigFiles: false, + })); + } catch (err: any) { + if (err instanceof ComponentNotFound) return undefined; + throw err; + } + const imported = importedIds[0]; + if (!imported) throw new BitError(`unable to clone, "${id}" was not imported`); + return imported; + } + + /** + * the same install `bit import` runs once its components are written, compile included. a failure + * does not undo the clone, the workspace is complete without it. + */ + private async installGracefully(): Promise { + try { + await this.install.install(undefined, { + dedupe: true, + updateExisting: false, + import: false, + writeConfigFiles: true, + }); + return undefined; + } catch (err: any) { + return err; + } + } +} + +/** + * makes a workspace out of a workspace-root component, the way `git clone` makes a working tree out of + * a repository: a fresh workspace in an empty directory, the root's files at its root, every component + * the root's `.bitmap` lists in the directory it records, then install. the versioned `.bitmap` has + * no versions on purpose (see normalizeBitmapContentForVersioning), so the components come at their + * heads - on main, or on the lane when one is given - and a root version pins the root files only. + * + * the directory must be empty or absent. there is no override: this is a new workspace, not an + * import into one, so nothing at the target is the user's. + */ +export async function cloneWorkspace( + rootId: ComponentID, + dir: string | undefined, + options: CloneOptions, + loadBit: LoadBit +): Promise { + const workspacePath = path.resolve(dir || rootId.name); + const createdDir = await ensureEmptyDir(workspacePath); + // the code paths below, from the workspace init to the install, take the workspace from the cwd + process.chdir(workspacePath); + try { + // only workspace.jsonc, .bitmap and the scope dir. the root files come from the component, and + // only what the component versions belongs at the root: no package.json, agent file or mcp config + await HostInitializerMain.init( + workspacePath, + false, + true, + false, + false, + false, + false, + false, + false, + {}, + undefined, + undefined, + { + skipDefaultMcp: true, + skipAgentInstructions: true, + } + ); + const harmony = await loadBit(workspacePath); + return await new WorkspaceCloner(harmony, workspacePath).clone(rootId, options); + } catch (err) { + // nothing half-made is left behind. the directory was empty or absent to begin with + if (createdDir) await fs.remove(workspacePath); + else await fs.emptyDir(workspacePath); + throw err; + } +} + +/** + * @returns whether the directory was created here, so a failed clone knows to remove it + */ +async function ensureEmptyDir(dirPath: string): Promise { + if (!(await fs.pathExists(dirPath))) { + await fs.ensureDir(dirPath); + return true; + } + const stat = await fs.stat(dirPath); + if (!stat.isDirectory()) throw new BitError(`unable to clone into "${dirPath}", it is not a directory`); + const entries = await fs.readdir(dirPath); + if (entries.length) throw new BitError(`unable to clone into "${dirPath}", the directory is not empty`); + return false; +} diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts index a5b469012541..8383836627cd 100644 --- a/scopes/workspace/workspace-root/index.ts +++ b/scopes/workspace/workspace-root/index.ts @@ -1,6 +1,7 @@ export { WorkspaceRootAspect, default } from './workspace-root.aspect'; export type { WorkspaceRootMain } from './workspace-root.main.runtime'; export type { WorkspaceRootData } from './workspace-root-data'; +export type { CloneOptions, CloneResult } from './clone'; export { findWorkspaceRootMap, isWorkspaceRootComponent, diff --git a/scopes/workspace/workspace-root/workspace-root.docs.mdx b/scopes/workspace/workspace-root/workspace-root.docs.mdx index 0a32eaf8971e..15bb43b43a2f 100644 --- a/scopes/workspace/workspace-root/workspace-root.docs.mdx +++ b/scopes/workspace/workspace-root/workspace-root.docs.mdx @@ -34,6 +34,25 @@ Both are aspect data, not config, so they never make a component modified and th does not touch its members. Their consumers are tools that need the root's files a version was made with, such as a CI building the component or a workspace clone. +## Cloning a workspace + +`bit clone [dir]` makes a workspace out of a workspace-root component, the way `git clone` +makes a working tree out of a repository. It runs outside a workspace, in an empty or absent +directory (default: the component name), and needs no `bit init`: + +- the root's files land at the workspace root, `workspace.jsonc` included; +- every component the root's versioned `.bitmap` lists is imported into the directory it records; +- the dependencies are installed and the components compiled (`--skip-dependency-installation` to skip). + +The versioned `.bitmap` holds no versions, only which components exist and where, so the components +come at their heads on main. `--lane /` clones the workspace as it is on a lane: the +workspace comes out on the lane and the components at their heads there. A version on the root id +pins the root files only. A component the root lists that its remote does not have, one never +exported for instance, is reported and the clone goes on without it. + +Scopes on bit.cloud resolve by name. For a self-hosted scope that is not in the global remotes, +`--remote ` registers it in the new workspace first. + ## Terminology "Workspace-root component", because "root component" is taken by the dependency-resolver's diff --git a/scopes/workspace/workspace-root/workspace-root.main.runtime.ts b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts index 715b2c5cc9c0..a7b8b2c499ce 100644 --- a/scopes/workspace/workspace-root/workspace-root.main.runtime.ts +++ b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts @@ -1,11 +1,16 @@ -import { MainRuntime } from '@teambit/cli'; +import { BitError } from '@teambit/bit-error'; +import type { CLIMain } from '@teambit/cli'; +import { CLIAspect, MainRuntime } from '@teambit/cli'; import type { Component, ComponentMain } from '@teambit/component'; import { ComponentAspect } from '@teambit/component'; -import type { ComponentID } from '@teambit/component-id'; +import { ComponentID } from '@teambit/component-id'; import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; +import type { CloneOptions, CloneResult, LoadBit } from './clone'; +import { cloneWorkspace } from './clone'; +import { CloneCmd } from './clone.cmd'; import { WorkspaceRootAspect } from './workspace-root.aspect'; import { WorkspaceRootFragment } from './workspace-root.fragment'; import type { WorkspaceRootData } from './workspace-root-data'; @@ -22,8 +27,33 @@ import { findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot } fro * rootComponents, or "workspace component", which is every component loaded from a workspace. */ export class WorkspaceRootMain { + private loadBit?: LoadBit; + constructor(private workspace?: Workspace) {} + /** + * a clone loads bit for the new workspace, in a process that started outside of one. the function + * belongs to the bit aspect, which depends on this one, so it is handed over rather than imported + * (see load-bit.ts). + */ + setLoadBit(loadBit: LoadBit) { + this.loadBit = loadBit; + } + + /** + * make a workspace out of a workspace-root component: its files at the root, every component it + * lists in the directory it records, then install. see cloneWorkspace. + */ + async clone(idStr: string, dir: string | undefined, options: CloneOptions = {}): Promise { + if (this.workspace) { + throw new BitError( + `unable to clone inside the workspace at "${this.workspace.path}", a clone is a new workspace. run it from a directory outside of any workspace` + ); + } + if (!this.loadBit) throw new Error('WorkspaceRootMain.clone: loadBit was not set, see load-bit.ts'); + return cloneWorkspace(ComponentID.fromString(idStr), dir, options, this.loadBit); + } + /** * the id of the component that owns the workspace root, if the workspace has one. */ @@ -56,11 +86,12 @@ export class WorkspaceRootMain { static runtime = MainRuntime; - static dependencies = [WorkspaceAspect, ComponentAspect]; + static dependencies = [WorkspaceAspect, ComponentAspect, CLIAspect]; - static async provider([workspace, component]: [Workspace | undefined, ComponentMain]) { + static async provider([workspace, component, cli]: [Workspace | undefined, ComponentMain, CLIMain]) { const workspaceRoot = new WorkspaceRootMain(workspace); component.registerShowFragments([new WorkspaceRootFragment(workspaceRoot)]); + cli.register(new CloneCmd(workspaceRoot)); workspace?.registerOnComponentLoad(markWorkspaceRoot); return workspaceRoot; } From 57708e58b46cbea742038e93dd0e95c2080aefe5 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 14:21:58 -0400 Subject: [PATCH 044/102] fix(workspace-root): validate cloned dirs, drop the lanes dependency, root-only .bitmap rules --- components/legacy/bit-map/bit-map.ts | 3 +- components/legacy/consumer/consumer.ts | 29 +++++++--- e2e/harmony/add-harmony.e2e.ts | 10 +++- pnpm-lock.yaml | 24 ++++++++ scopes/component/checkout/checkout-version.ts | 7 ++- scopes/component/tracker/add-components.ts | 4 +- scopes/workspace/workspace-root/clone.spec.ts | 30 ++++++++++ scopes/workspace/workspace-root/clone.ts | 55 +++++++++++++------ 8 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 scopes/workspace/workspace-root/clone.spec.ts diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 1b4f814f6718..8bcbaed1670f 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1135,7 +1135,8 @@ export type VersionedBitmapEntry = { * exported when the root was versioned, its default scope: the export that follows carries both. */ id: string; - rootDir: PathLinuxRelative; + /** every entry of the current schema has one; a consumer refuses an entry without it */ + rootDir?: PathLinuxRelative; }; /** diff --git a/components/legacy/consumer/consumer.ts b/components/legacy/consumer/consumer.ts index b763b12bc7b9..3be3a251048f 100644 --- a/components/legacy/consumer/consumer.ts +++ b/components/legacy/consumer/consumer.ts @@ -32,7 +32,7 @@ import type { } from '@teambit/legacy.utils'; import { parseScope } from '@teambit/legacy.utils'; import type { NextVersion } from '@teambit/legacy.bit-map'; -import { BitMap } from '@teambit/legacy.bit-map'; +import { BitMap, isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { Dependencies, ComponentLoadOptions, LoadManyResult } from '@teambit/legacy.consumer-component'; import { ConsumerComponent as Component, ComponentLoader } from '@teambit/legacy.consumer-component'; import { PackageJsonFile } from '@teambit/component.sources'; @@ -351,15 +351,26 @@ export default class Consumer { fileFromFs.test = fileFromModel.test; }); - // prefix your command with "BIT_LOG=*" to see the actual id changes - if (process.env.BIT_LOG && componentFromModel.calculateHash().hash !== version.calculateHash().hash) { - console.log('-------------------componentFromModel------------------------'); // eslint-disable-line no-console - console.log(componentFromModel.id()); // eslint-disable-line no-console - console.log('------------------------componentFromFileSystem (version)----'); // eslint-disable-line no-console - console.log(version.id()); // eslint-disable-line no-console - console.log('-------------------------END---------------------------------'); // eslint-disable-line no-console + // a workspace-root component written below the workspace root has its versioned .bitmap left + // out (see isWorkspaceMapFile): the live map is the workspace's own. there the map stays behind, + // and its absence is not a modification. + const modelFiles = componentFromModel.files; + if (componentFromFileSystem.componentMap?.rootDir !== WORKSPACE_ROOT_DIR) { + componentFromModel.files = modelFiles.filter((file) => !isWorkspaceMapFile(file.relativePath)); + } + try { + // prefix your command with "BIT_LOG=*" to see the actual id changes + if (process.env.BIT_LOG && componentFromModel.calculateHash().hash !== version.calculateHash().hash) { + console.log('-------------------componentFromModel------------------------'); // eslint-disable-line no-console + console.log(componentFromModel.id()); // eslint-disable-line no-console + console.log('------------------------componentFromFileSystem (version)----'); // eslint-disable-line no-console + console.log(version.id()); // eslint-disable-line no-console + console.log('-------------------------END---------------------------------'); // eslint-disable-line no-console + } + componentFromFileSystem._isModified = componentFromModel.calculateHash().hash !== version.calculateHash().hash; + } finally { + componentFromModel.files = modelFiles; } - componentFromFileSystem._isModified = componentFromModel.calculateHash().hash !== version.calculateHash().hash; } return componentFromFileSystem._isModified; diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 784c4cdff80f..a42618aa45d1 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -302,6 +302,9 @@ describe('add command on Harmony', function () { const bitmaps = helper.fs.getConsumerFiles('.bitmap', true, false); expect(bitmaps).to.deep.equal([path.normalize('.bitmap')]); }); + it('should not be modified by the map it left behind', () => { + expect(helper.command.statusJson().modifiedComponents).to.deep.equal([]); + }); }); describe('importing it onto the root of an empty workspace', () => { // this is how a git-free workspace is restored from its scope: the root component's files @@ -433,8 +436,10 @@ describe('add command on Harmony', function () { }); }); describe('env of the workspace-root component', () => { + // one status per workspace state, the assertions read from it + let status: Record; const issuesOf = (name: string): string[] => { - const comp = helper.command.statusJson().componentsWithIssues.find((c) => c.id.includes(name)); + const comp = status.componentsWithIssues.find((c) => c.id.includes(name)); return comp ? comp.issues.map((issue) => issue.type) : []; }; before(() => { @@ -443,6 +448,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); helper.command.addComponent('.', { i: 'ws-root' }); + status = helper.command.statusJson(); }); it('should be tracked with the empty env, not the regular default env', () => { // it is a bag of the workspace's own config files - nothing compiles it, tests it, or @@ -478,11 +484,13 @@ describe('add command on Harmony', function () { // the default remote scope of the e2e has no owner prefix, so the package name has none either const wouldBePackageName = helper.general.getPackageNameByCompName('ws-root', false); helper.workspaceJsonc.addPolicyToDependencyResolver({ dependencies: { [wouldBePackageName]: '1.0.0' } }); + status = helper.command.statusJson(); expect(issuesOf('ws-root')).to.not.include('DuplicateComponentAndPackage'); }); describe('when a root file has a relative import into a component and requires a missing package', () => { before(() => { helper.fs.outputFile('app.js', "require('./comp1');\nrequire('some-package-that-is-not-installed');\n"); + status = helper.command.statusJson(); }); it('should report no issue, the root files are not parsed for dependencies', () => { // the root is the workspace itself: config files and repo scripts that may require anything. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74739d8b0ac7..b9b9b953b00a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25640,12 +25640,18 @@ importers: scopes/workspace/workspace-root: dependencies: + '@teambit/bit-error': + specifier: ~0.0.404 + version: 0.0.404 '@teambit/component-id': specifier: ^1.2.4 version: 1.2.4 '@teambit/harmony': specifier: 0.4.12 version: 0.4.12 + '@teambit/lane-id': + specifier: ~0.0.312 + version: 0.0.312 '@testing-library/react': specifier: ^14.3.1 version: 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) @@ -25661,6 +25667,9 @@ importers: chai: specifier: 5.2.1 version: 5.2.1 + fs-extra: + specifier: 10.0.0 + version: 10.0.0 react: specifier: 19.2.7 version: 19.2.7 @@ -25674,6 +25683,9 @@ importers: '@teambit/harmony.envs.core-aspect-env': specifier: 2.1.1 version: 2.1.1(@babel/core@7.28.3)(@babel/traverse@7.29.8)(eslint@8.56.0)(react-refresh@0.10.0)(react-test-renderer@19.2.7)(rollup@2.80.0)(sass-embedded@1.102.0)(supports-color@9.4.0)(webpack@5.109.2) + '@types/fs-extra': + specifier: 9.0.7 + version: 9.0.7 packages: @@ -83402,16 +83414,22 @@ snapshots: '@teambit/workspace-root@file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0)': dependencies: + '@teambit/bit-error': 0.0.404 '@teambit/component-id': 1.2.4 '@teambit/harmony': 0.4.12 + '@teambit/lane-id': 0.0.312 '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.consumer-component': file:components/legacy/consumer-component(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.extension-data': file:components/legacy/extension-data + '@teambit/legacy.scope': file:components/legacy/scope(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.utils': file:components/legacy/utils + '@teambit/scope.remotes': file:components/scope/remotes(graphql@15.8.0) '@testing-library/react': 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) '@types/node': 22.10.5 '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) chai: 4.3.0 + fs-extra: 10.0.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-router-dom: 6.30.4(react-dom@19.2.7)(react@19.2.7) @@ -83423,16 +83441,22 @@ snapshots: '@teambit/workspace-root@file:scopes/workspace/workspace-root(chai@5.2.1)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0)': dependencies: + '@teambit/bit-error': 0.0.404 '@teambit/component-id': 1.2.4 '@teambit/harmony': 0.4.12 + '@teambit/lane-id': 0.0.312 '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.consumer-component': file:components/legacy/consumer-component(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.extension-data': file:components/legacy/extension-data + '@teambit/legacy.scope': file:components/legacy/scope(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.utils': file:components/legacy/utils + '@teambit/scope.remotes': file:components/scope/remotes(graphql@15.8.0) '@testing-library/react': 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) '@types/node': 22.10.5 '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) chai: 5.2.1 + fs-extra: 10.0.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-router-dom: 6.30.4(react-dom@19.2.7)(react@19.2.7) diff --git a/scopes/component/checkout/checkout-version.ts b/scopes/component/checkout/checkout-version.ts index 627b42a4c7cb..3a8127518cd5 100644 --- a/scopes/component/checkout/checkout-version.ts +++ b/scopes/component/checkout/checkout-version.ts @@ -4,7 +4,7 @@ import type { Version } from '@teambit/objects'; import type { SourceFile } from '@teambit/component.sources'; import { RemovePath, DataToPersist } from '@teambit/component.sources'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; -import { isWorkspaceMapFile } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; import { BitError } from '@teambit/bit-error'; import chalk from 'chalk'; @@ -128,8 +128,9 @@ export async function removeFilesIfNeeded( const dataToPersist = new DataToPersist(); filePathsFromFS.forEach((file) => { const filename = pathNormalizeToLinux(file.relative); - // the live .bitmap is never deleted from a versioned copy, see isWorkspaceMapFile - if (isWorkspaceMapFile(filename)) return; + // the live .bitmap is never deleted from a versioned copy, see isWorkspaceMapFile. only the root + // component tracks it, an ordinary component's is a file like any other. + if (isWorkspaceMapFile(filename) && componentFromFS.componentMap?.rootDir === WORKSPACE_ROOT_DIR) return; if (!filesStatus[filename]) { // @ts-ignore todo: typescript has a good point here. it should be the string "removed", not chalk.green(removed). filesStatus[filename] = FileStatus.removed; diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 15ced1c99275..bf64d3948097 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -310,7 +310,9 @@ export default class AddComponents { return foundComponentFromBitMap; } } - if (!this.override && foundComponentFromBitMap) { + // the root's files are whatever the scan says. a component nested since the previous add owns the + // files the previous entry listed for the root, so they are not merged back. + if (!this.override && foundComponentFromBitMap && !isWorkspaceRoot) { this._updateFilesWithCurrentLetterCases(foundComponentFromBitMap, componentFiles); component.files = this._mergeFilesWithExistingComponentMapFiles(componentFiles, foundComponentFromBitMap.files); } else { diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts new file mode 100644 index 000000000000..626656d1faf1 --- /dev/null +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai'; +import os from 'os'; +import path from 'path'; +import { resolveComponentDir } from './clone'; + +describe('resolveComponentDir', () => { + const workspacePath = path.resolve(os.tmpdir(), 'ws'); + it('should resolve a root-dir inside the workspace', () => { + expect(resolveComponentDir(workspacePath, { id: 'a', rootDir: 'comps/a' })).to.equal( + path.join(workspacePath, 'comps', 'a') + ); + }); + it('should refuse a root-dir that climbs out of the workspace, the list comes from a remote', () => { + const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir: '../a' }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); + it('should refuse an absolute root-dir', () => { + const resolve = () => + resolveComponentDir(workspacePath, { id: 'a', rootDir: path.resolve(os.tmpdir(), 'elsewhere') }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); + it('should refuse the workspace root itself, only the root component owns it', () => { + const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir: '.' }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); + it('should refuse an entry without a root-dir rather than fail on it later', () => { + const resolve = () => resolveComponentDir(workspacePath, { id: 'a' }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); +}); diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 49ff32c67bdb..ab6bbcccd8a7 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -8,9 +8,7 @@ import type { ImporterMain } from '@teambit/importer'; import { ImporterAspect } from '@teambit/importer'; import type { InstallMain } from '@teambit/install'; import { InstallAspect } from '@teambit/install'; -import type { LaneId } from '@teambit/lane-id'; -import type { LanesMain } from '@teambit/lanes'; -import { LanesAspect } from '@teambit/lanes'; +import { LaneId } from '@teambit/lane-id'; import type { VersionedBitmapEntry } from '@teambit/legacy.bit-map'; import { isWorkspaceMapFile, readVersionedBitmapEntries, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { ComponentNotFound } from '@teambit/legacy.scope'; @@ -61,7 +59,6 @@ export type CloneResult = { class WorkspaceCloner { private workspace: Workspace; private importer: ImporterMain; - private lanes: LanesMain; private scope: ScopeMain; private install: InstallMain; @@ -71,7 +68,6 @@ class WorkspaceCloner { ) { this.workspace = harmony.get(WorkspaceAspect.id); this.importer = harmony.get(ImporterAspect.id); - this.lanes = harmony.get(LanesAspect.id); this.scope = harmony.get(ScopeAspect.id); this.install = harmony.get(InstallAspect.id); } @@ -80,12 +76,12 @@ class WorkspaceCloner { if (options.remote) await this.addRemote(options.remote); const laneId = options.lane ? await this.switchToLane(options.lane) : undefined; const { versionedRootId, entries } = await this.fetchRoot(rootId); - await this.write(versionedRootId.toString(), WORKSPACE_ROOT_DIR); + await this.write(versionedRootId.toString(), this.workspacePath); const components: ComponentID[] = []; const missing: string[] = []; for (const entry of entries) { if (entry.rootDir === WORKSPACE_ROOT_DIR) continue; - const written = await this.write(entry.id, entry.rootDir); + const written = await this.write(entry.id, resolveComponentDir(this.workspacePath, entry)); if (written) components.push(written); else missing.push(entry.id); } @@ -109,10 +105,10 @@ class WorkspaceCloner { } /** - * the workspace comes out on the lane with nothing written: restricted to the workspace's own - * components, a switch in an empty workspace has none to check out. it fetches the lane and the - * objects of its components on the way, and the imports that follow take the lane heads because - * the workspace is on it. + * the workspace comes out on the lane with nothing written, the way a lane switch leaves it when it + * has nothing to check out (see LaneSwitcher.saveLanesData): the lane and the objects of its + * components are fetched, the lane is tracked and made current. the imports that follow take the + * lane heads because the workspace is on it. */ private async switchToLane(lane: string): Promise { if (!lane.includes('/')) { @@ -120,9 +116,14 @@ class WorkspaceCloner { `unable to clone from lane "${lane}", the lane must be given with its scope, e.g. "my-org.my-scope/${lane}"` ); } - await this.lanes.switchLanes(lane, { workspaceOnly: true, skipDependencyInstallation: true }); - const laneId = this.lanes.getCurrentLaneId(); - if (!laneId) throw new Error(`clone: the workspace is not on lane "${lane}" after switching to it`); + const laneId = LaneId.parse(lane); + const remoteLane = await this.importer.importLaneObject(laneId); + await this.importer.fetchLaneComponents(remoteLane); + const consumer = this.workspace.consumer; + consumer.scope.lanes.trackLane({ localLane: laneId.name, remoteLane: laneId.name, remoteScope: laneId.scope }); + consumer.setCurrentLane(laneId, true); + consumer.scope.objects.clearObjectsFromCache(); + await consumer.writeBitMap('clone'); return laneId; } @@ -158,12 +159,12 @@ class WorkspaceCloner { * @returns undefined when the remote does not have the component. a root versioned before the first * export lists its members by their default scope, and a member may have stayed behind. */ - private async write(id: string, rootDir: string): Promise { + private async write(id: string, dir: string): Promise { let importedIds: ComponentID[]; try { ({ importedIds } = await this.importer.import({ ids: [id], - writeToPath: path.join(this.workspacePath, rootDir), + writeToPath: dir, installNpmPackages: false, writeConfigFiles: false, })); @@ -214,6 +215,7 @@ export async function cloneWorkspace( const workspacePath = path.resolve(dir || rootId.name); const createdDir = await ensureEmptyDir(workspacePath); // the code paths below, from the workspace init to the install, take the workspace from the cwd + const originalCwd = process.cwd(); process.chdir(workspacePath); try { // only workspace.jsonc, .bitmap and the scope dir. the root files come from the component, and @@ -239,13 +241,32 @@ export async function cloneWorkspace( const harmony = await loadBit(workspacePath); return await new WorkspaceCloner(harmony, workspacePath).clone(rootId, options); } catch (err) { - // nothing half-made is left behind. the directory was empty or absent to begin with + // the caller is left where it was, and nothing half-made is left behind: the directory was empty + // or absent to begin with + process.chdir(originalCwd); if (createdDir) await fs.remove(workspacePath); else await fs.emptyDir(workspacePath); throw err; } } +/** + * the directory of a component the root lists, inside the workspace. the list comes from a remote, + * so a root-dir that escapes the workspace - "../x", an absolute path - is refused, and so are the + * workspace root itself, which only the root component owns, and a missing root-dir, which no + * `.bitmap` of the current schema has. + */ +export function resolveComponentDir(workspacePath: string, entry: VersionedBitmapEntry): string { + const target = entry.rootDir ? path.resolve(workspacePath, entry.rootDir) : undefined; + const relative = target ? path.relative(workspacePath, target) : undefined; + if (!target || !relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new BitError( + `unable to clone, the root component lists "${entry.id}" at "${entry.rootDir}", which is not a directory inside the workspace` + ); + } + return target; +} + /** * @returns whether the directory was created here, so a failed clone knows to remove it */ From 216f3ee46db12e97c4674118b9f5452f8fc1ec67 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 14:44:09 -0400 Subject: [PATCH 045/102] fix(workspace-root): name the scope when the cloned root is not on the remote, tolerate missing members --- e2e/harmony/add-harmony.e2e.ts | 11 +++++++++++ scopes/workspace/workspace-root/clone.ts | 20 +++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index a42618aa45d1..b4eb3f71b9d9 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -594,6 +594,17 @@ describe('add command on Harmony', function () { expect(cmd).to.throw('not a workspace-root component'); expect(clonePath).to.not.be.a.path(); }); + it('should refuse a component the remote does not have, pointing at the export', () => { + // the importer reports it as missing rather than throwing, e.g. a root tagged but never exported + const clonePath = path.join(helper.scopes.e2eDir, 'never-exported'); + const cmd = () => + helper.command.runCmd( + `bit clone ${helper.scopes.remote}/never-exported ${clonePath} -x`, + helper.scopes.e2eDir + ); + expect(cmd).to.throw(`the remote scope "${helper.scopes.remote}" does not have`); + expect(clonePath).to.not.be.a.path(); + }); }); }); describe('cloning a workspace as it is on a lane', () => { diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index ab6bbcccd8a7..03dcee26b312 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -134,14 +134,22 @@ class WorkspaceCloner { private async fetchRoot( rootId: ComponentID ): Promise<{ versionedRootId: ComponentID; entries: VersionedBitmapEntry[] }> { - const { importedIds } = await this.importer.import({ + const { importedIds, missingIds } = await this.importer.import({ ids: [rootId.toString()], objectsOnly: true, installNpmPackages: false, writeConfigFiles: false, }); const versionedRootId = importedIds.find((id) => id.isEqualWithoutVersion(rootId)); - if (!versionedRootId) throw new BitError(`unable to clone, "${rootId.toString()}" was not imported`); + if (!versionedRootId) { + // the importer reports a component the remote does not have rather than throwing + if (missingIds?.length) { + throw new BitError( + `unable to clone, the remote scope "${rootId.scope}" does not have "${rootId.toString()}". a workspace is cloned from an exported workspace-root component, run "bit export" in its workspace first` + ); + } + throw new BitError(`unable to clone, "${rootId.toString()}" was not imported`); + } const rootComponent = await this.scope.legacyScope.getConsumerComponent(versionedRootId); if (!isWorkspaceRootComponent(rootComponent.extensions)) { throw new BitError( @@ -161,8 +169,9 @@ class WorkspaceCloner { */ private async write(id: string, dir: string): Promise { let importedIds: ComponentID[]; + let missingIds: string[] | undefined; try { - ({ importedIds } = await this.importer.import({ + ({ importedIds, missingIds } = await this.importer.import({ ids: [id], writeToPath: dir, installNpmPackages: false, @@ -173,8 +182,9 @@ class WorkspaceCloner { throw err; } const imported = importedIds[0]; - if (!imported) throw new BitError(`unable to clone, "${id}" was not imported`); - return imported; + if (imported) return imported; + if (missingIds?.length) return undefined; + throw new BitError(`unable to clone, "${id}" was not imported`); } /** From bcfb8b3c81f43f170d068d24db94f2a6f0767815 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 16:00:24 -0400 Subject: [PATCH 046/102] perf(importer): write a set of components to their own directories in one import, use it in clone --- .../component-writer.main.runtime.ts | 38 +++++++--- scopes/scope/importer/import-components.ts | 8 +++ scopes/workspace/workspace-root/clone.ts | 69 ++++++++++--------- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 0734443066c8..a6f0336dbc3b 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -34,6 +34,12 @@ import { ComponentWriterAspect } from './component-writer.aspect'; export interface ManyComponentsWriterParams { components: ConsumerComponent[]; writeToPath?: string; + /** + * a directory per component, keyed by the id without its version, for writing a set of components + * that each go to its own place - as "--path" does for a whole batch. a component the map does not + * name falls back to writeToPath, and then to the default directory. + */ + writeToPathPerId?: Record; throwForExistingDir?: boolean; // when the target dir is occupied, import into an available empty dir (e.g. "foo" => "foo_1") instead of failing. writeToEmptyDir?: boolean; @@ -276,9 +282,9 @@ export class ComponentWriterMain { ): ComponentWriterProps { // "--path ." resolves to an empty relative path. normalize it to "." so it is a real rootDir // rather than a falsy one that later turns into an undefined path segment. - const componentRootDir: PathLinuxRelative = opts.writeToPath - ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) || - WORKSPACE_ROOT_DIR + const writeToPath = this.getWriteToPath(component, opts); + const componentRootDir: PathLinuxRelative = writeToPath + ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(writeToPath))) || WORKSPACE_ROOT_DIR : this.consumer.composeRelativeComponentPath(component.id); // components can't be saved with multiple versions, so we can ignore the version to find the component in bit.map const existingComponentMap = this.consumer?.bitMap.getComponentIfExist(component.id, { ignoreVersion: true }); @@ -292,7 +298,7 @@ export class ComponentWriterMain { // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. // the workspace root is never relocated (see relocateOccupiedDirs), so its own check runs either way. if (this.consumer && (!opts.writeToEmptyDir || componentRootDir === WORKSPACE_ROOT_DIR)) { - this.throwErrorWhenDirectoryNotEmpty(component, componentRootDir, existingComponentMap, opts); + this.throwErrorWhenDirectoryNotEmpty(component, componentRootDir, existingComponentMap, opts, writeToPath); } return { workspace: this.workspace, @@ -304,9 +310,19 @@ export class ComponentWriterMain { existingComponentMap: existingComponentMap ?? undefined, }; } + /** + * where this component was asked to go, if anywhere: its own entry in writeToPathPerId, else the + * writeToPath of the whole batch. + */ + private getWriteToPath(component: ConsumerComponent, opts: ManyComponentsWriterParams): string | undefined { + return opts.writeToPathPerId?.[component.id.toStringWithoutVersion()] || opts.writeToPath; + } + private moveComponentsIfNeeded(opts: ManyComponentsWriterParams) { - if (opts.writeToPath && this.consumer) { + if (this.consumer) { opts.components.forEach((component) => { + const writeToPath = this.getWriteToPath(component, opts); + if (!writeToPath) return; const componentMap = component.componentMap as ComponentMap; if (!componentMap.rootDir) { throw new BitError(`unable to use "--path" flag. @@ -317,7 +333,7 @@ to move all component files to a different directory, run bit remove and then bi // @ts-ignore relativeWrittenPath is set at this point const absoluteWrittenPath = this.consumer.toAbsolutePath(relativeWrittenPath); // @ts-ignore this.writeToPath is set at this point - const absoluteWriteToPath = path.resolve(opts.writeToPath); // don't use consumer.toAbsolutePath, it might be an inner dir + const absoluteWriteToPath = path.resolve(writeToPath); // don't use consumer.toAbsolutePath, it might be an inner dir if (relativeWrittenPath && absoluteWrittenPath !== absoluteWriteToPath) { this.mover.moveExistingComponent(component, absoluteWrittenPath, absoluteWriteToPath); } @@ -331,12 +347,13 @@ to move all component files to a different directory, run bit remove and then bi private shouldSkipDirConflictCheck( componentDirRelative: PathLinuxRelative, componentMap: ComponentMap | null | undefined, - opts: ManyComponentsWriterParams + opts: ManyComponentsWriterParams, + writeToPath?: string ): boolean { if (opts.skipWritingToFs) return true; if (!componentMap) return false; // no writeToPath: it goes to the default directory. an existing componentMap means the component is not new. - if (!opts.writeToPath) return true; + if (!writeToPath) return true; // writeToPath specified and that directory is already used for that component. compare against the // normalized componentDirRelative (not the raw opts.writeToPath, which may be absolute/OS-specific). return componentMap.rootDir === componentDirRelative; @@ -424,9 +441,10 @@ use --override to overwrite them` component: ConsumerComponent, componentDirRelative: PathLinuxRelative, componentMap: ComponentMap | null | undefined, - opts: ManyComponentsWriterParams + opts: ManyComponentsWriterParams, + writeToPath?: string ) { - if (this.shouldSkipDirConflictCheck(componentDirRelative, componentMap, opts)) return; + if (this.shouldSkipDirConflictCheck(componentDirRelative, componentMap, opts, writeToPath)) return; const componentDir = this.consumer.toAbsolutePath(componentDirRelative); if (!fs.pathExistsSync(componentDir)) return; diff --git a/scopes/scope/importer/import-components.ts b/scopes/scope/importer/import-components.ts index bb34d75728fb..6c991792f825 100644 --- a/scopes/scope/importer/import-components.ts +++ b/scopes/scope/importer/import-components.ts @@ -44,6 +44,12 @@ export type ImportOptions = { mergeStrategy?: MergeStrategy; filterEnvs?: string[]; writeToPath?: string; + /** + * a directory per component, keyed by the id without its version, for writing components that each + * go to its own place in one import rather than one import per directory (see the same prop on + * ManyComponentsWriterParams). + */ + writeToPathPerId?: Record; writeConfig?: boolean; override?: boolean; installNpmPackages: boolean; // default: true @@ -387,6 +393,7 @@ export default class ImportComponents { return { components, writeToPath: this.options.writeToPath, + writeToPathPerId: this.options.writeToPathPerId, writeConfig: this.options.writeConfig, skipDependencyInstallation: !this.options.installNpmPackages, skipWriteConfigFiles: !this.options.writeConfigFiles, @@ -1096,6 +1103,7 @@ otherwise, if tagged/snapped, "bit reset" it, then bit rename it.`); const manyComponentsWriterOpts: ManyComponentsWriterParams = { components: componentsToWrite, writeToPath: this.options.writeToPath, + writeToPathPerId: this.options.writeToPathPerId, writeConfig: this.options.writeConfig, skipDependencyInstallation: !this.options.installNpmPackages, skipWriteConfigFiles: !this.options.writeConfigFiles, diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 03dcee26b312..9a527412885d 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -11,7 +11,6 @@ import { InstallAspect } from '@teambit/install'; import { LaneId } from '@teambit/lane-id'; import type { VersionedBitmapEntry } from '@teambit/legacy.bit-map'; import { isWorkspaceMapFile, readVersionedBitmapEntries, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; -import { ComponentNotFound } from '@teambit/legacy.scope'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ScopeMain } from '@teambit/scope'; import { ScopeAspect } from '@teambit/scope'; @@ -76,15 +75,8 @@ class WorkspaceCloner { if (options.remote) await this.addRemote(options.remote); const laneId = options.lane ? await this.switchToLane(options.lane) : undefined; const { versionedRootId, entries } = await this.fetchRoot(rootId); - await this.write(versionedRootId.toString(), this.workspacePath); - const components: ComponentID[] = []; - const missing: string[] = []; - for (const entry of entries) { - if (entry.rootDir === WORKSPACE_ROOT_DIR) continue; - const written = await this.write(entry.id, resolveComponentDir(this.workspacePath, entry)); - if (written) components.push(written); - else missing.push(entry.id); - } + await this.writeRoot(versionedRootId); + const { components, missing } = await this.writeMembers(entries); const installationError = options.skipDependencyInstallation ? undefined : await this.installGracefully(); return { rootId: versionedRootId, @@ -162,29 +154,44 @@ class WorkspaceCloner { } /** - * as `bit import --path ` does. one import per component, the path is the batch's. + * the root's own files, onto the workspace directory, as `bit import --path ` does. it + * goes before the members: the guards of the root let the files `bit init` generated be replaced + * only while the workspace is still empty of components. + */ + private async writeRoot(versionedRootId: ComponentID) { + const { importedIds } = await this.importer.import({ + ids: [versionedRootId.toString()], + writeToPath: this.workspacePath, + installNpmPackages: false, + writeConfigFiles: false, + }); + if (!importedIds.length) throw new BitError(`unable to clone, "${versionedRootId.toString()}" was not imported`); + } + + /** + * every component the root lists, in one import - each to the directory the root recorded for it. * - * @returns undefined when the remote does not have the component. a root versioned before the first - * export lists its members by their default scope, and a member may have stayed behind. + * a component the remote does not have is reported rather than fetched: a root versioned before the + * first export lists its members by their default scope, and a member may have stayed behind. */ - private async write(id: string, dir: string): Promise { - let importedIds: ComponentID[]; - let missingIds: string[] | undefined; - try { - ({ importedIds, missingIds } = await this.importer.import({ - ids: [id], - writeToPath: dir, - installNpmPackages: false, - writeConfigFiles: false, - })); - } catch (err: any) { - if (err instanceof ComponentNotFound) return undefined; - throw err; - } - const imported = importedIds[0]; - if (imported) return imported; - if (missingIds?.length) return undefined; - throw new BitError(`unable to clone, "${id}" was not imported`); + private async writeMembers( + entries: VersionedBitmapEntry[] + ): Promise<{ components: ComponentID[]; missing: string[] }> { + const writeToPathPerId: Record = {}; + entries.forEach((entry) => { + if (entry.rootDir === WORKSPACE_ROOT_DIR) return; + writeToPathPerId[entry.id] = resolveComponentDir(this.workspacePath, entry); + }); + const ids = Object.keys(writeToPathPerId); + if (!ids.length) return { components: [], missing: [] }; + const { importedIds, missingIds } = await this.importer.import({ + ids, + writeToPathPerId, + installNpmPackages: false, + writeConfigFiles: false, + }); + const components = importedIds.filter((id) => writeToPathPerId[id.toStringWithoutVersion()]); + return { components, missing: missingIds || [] }; } /** From 47c9c0b8955778830d29c0a34dff70329132f628 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 16:00:30 -0400 Subject: [PATCH 047/102] fix(bit-map): version a component's scope in one field, so the first export stops modifying the root --- components/legacy/bit-map/bit-map.spec.ts | 16 ++++++++++++++-- components/legacy/bit-map/bit-map.ts | 19 +++++++++++++------ e2e/harmony/add-harmony.e2e.ts | 15 ++++++++------- .../workspace-root/workspace-root.docs.mdx | 3 +++ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 6f5b1ed21857..a2656e79094a 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -183,11 +183,23 @@ describe('BitMap', function () { it('should keep the scope, so cross-scope components survive a restore', () => { expect(parsed.comp1.scope).to.equal('my-scope'); }); + it('should resolve the scope into one field, the export moves it from defaultScope to scope', () => { + // otherwise the first export modifies the root, and a clone of it is born modified + expect(parsed.comp1).to.not.have.property('defaultScope'); + }); + it('should read the scope of a component not exported yet from its defaultScope', () => { + const notExported = JSON.stringify({ comp2: { name: 'comp2', scope: '', defaultScope: 'my-org.demo' } }); + const normalizedNotExported = normalizeBitmapContentForVersioning(notExported); + expect(JSON.parse(normalizedNotExported.slice(normalizedNotExported.indexOf('{'))).comp2.scope).to.equal( + 'my-org.demo' + ); + }); + it('should drop the schema of the file, which a bit upgrade rewrites', () => { + expect(parsed).to.not.have.property('$schema-version'); + }); it('should keep the durable map intact', () => { expect(parsed.comp1.rootDir).to.equal('comp1'); expect(parsed.comp1.mainFile).to.equal('index.ts'); - expect(parsed.comp1.defaultScope).to.equal('my-org.demo'); - expect(parsed['$schema-version']).to.equal('17.0.0'); }); it('should be idempotent, otherwise the root component would never converge', () => { expect(normalizeBitmapContentForVersioning(normalized)).to.equal(normalized); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 8bcbaed1670f..6c4c088945ed 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1106,11 +1106,12 @@ type OutputFileParams = { * which components exist and where they live. the versions and the config are restored from the * component heads on import, which is the correct source for them anyway. * - * `scope` is deliberately kept: it changes once (on the first export) and is then stable, so it - * costs one extra snap rather than perpetual drift. clearing it would lose the identity of - * components belonging to a scope other than the workspace default - on restore they would all - * collapse onto the default scope, and same-named components from different scopes would overwrite - * each other. + * which scope a component belongs to is part of that durable part and is kept, or components of a + * scope other than the workspace default would all collapse onto the default on restore, and + * same-named components from different scopes would overwrite each other. it is kept in one field: + * a component carries it in `defaultScope` until it is exported and in `scope` from then on, same + * scope either way, so resolving the two into `scope` is what keeps an export from modifying the + * root - and a clone of it, whose components are exported by definition, from being born modified. */ export function normalizeBitmapContentForVersioning(rawContent: string): string { const parsed = json.parse(rawContent, undefined, true) as Record | undefined; @@ -1118,10 +1119,16 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string // the lane the workspace is on, and whether it was exported: workspace state that export and // lane switch change, not part of the map of components delete parsed[LANE_KEY]; + // the schema of the file, which a bit upgrade rewrites. the map it describes is what is versioned + delete parsed[SCHEMA_FIELD]; Object.keys(parsed).forEach((key) => { const entry = parsed[key]; - if (key === SCHEMA_FIELD || !entry || typeof entry !== 'object') return; + if (!entry || typeof entry !== 'object') return; if (entry.version !== undefined) entry.version = ''; + if (entry.scope !== undefined || entry.defaultScope !== undefined) { + entry.scope = entry.scope || entry.defaultScope || ''; + delete entry.defaultScope; + } delete entry.config; // the pending soft-tag, which --persist turns into a version and clears from the map delete entry.nextVersion; diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index b4eb3f71b9d9..53bf1c5e0c96 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -558,12 +558,12 @@ describe('add command on Harmony', function () { expect(path.join(helper.scopes.localPath, 'comp1/package.json')).to.be.a.file(); expect(path.join(helper.scopes.localPath, 'comp1/tsconfig.json')).to.be.a.file(); }); - it('should reproduce the state of the source workspace, whose root the export left modified', () => { - // the export set the scopes in .bitmap after the root was snapped, so the versioned map lists - // the members by their default scope. the clone resolves them by it, and the root is modified - // in the clone as it is at the source, until the next snap takes it along. + it('should come out clean, the workspace it reproduces is the one the root was versioned from', () => { + // the versioned map lists the members by their default scope, the export having set the scopes + // in .bitmap only after the root was snapped, and the clone writes them by their scope. one is + // the other before the export and after it, which is what the versioned map is normalized on. const status = helper.command.statusJson(); - expect(status.modifiedComponents).to.deep.equal([`${helper.scopes.remote}/ws-root`]); + expect(status.modifiedComponents).to.have.lengthOf(0); expect(status.newComponents).to.have.lengthOf(0); }); it('should refuse to run inside a workspace', () => { @@ -624,9 +624,10 @@ describe('add command on Harmony', function () { comp1MainHead = helper.command.getHead('comp1'); comp2MainHead = helper.command.getHead('comp2'); helper.command.createLane('dev'); - // comp1 changes on the lane. the export set the scopes in .bitmap, so the root is modified and - // joins the snap on the lane as well. comp2 stays as it is on main. + // comp1 and a file of the root itself change on the lane, so both get a head there. comp2 stays + // as it is on main. helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1 v2";\n'); + helper.fs.outputFile('README.md', '# on the lane\n'); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); helper.command.export(); comp1LaneHead = helper.command.getHeadOfLane('dev', 'comp1'); diff --git a/scopes/workspace/workspace-root/workspace-root.docs.mdx b/scopes/workspace/workspace-root/workspace-root.docs.mdx index 15bb43b43a2f..b530a7ca6cf2 100644 --- a/scopes/workspace/workspace-root/workspace-root.docs.mdx +++ b/scopes/workspace/workspace-root/workspace-root.docs.mdx @@ -50,6 +50,9 @@ workspace comes out on the lane and the components at their heads there. A versi pins the root files only. A component the root lists that its remote does not have, one never exported for instance, is reported and the clone goes on without it. +A clone comes out with nothing modified. What the root versions of its `.bitmap` is normalized to the +part an export does not change, so the map the clone writes is the map the root carries. + Scopes on bit.cloud resolve by name. For a self-hosted scope that is not in the global remotes, `--remote ` registers it in the new workspace first. From fbbf9b43f544bf55228abe86064ca31919196133 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 16:35:54 -0400 Subject: [PATCH 048/102] fix(workspace-root): refuse a symlinked clone target, keep dotted dir names, join the output with the toolkit --- scopes/workspace/workspace-root/clone.cmd.ts | 4 +- scopes/workspace/workspace-root/clone.spec.ts | 52 ++++++++++++++++++- scopes/workspace/workspace-root/clone.ts | 30 +++++++++-- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/scopes/workspace/workspace-root/clone.cmd.ts b/scopes/workspace/workspace-root/clone.cmd.ts index 43b1e0e32d4c..d2a47456b144 100644 --- a/scopes/workspace/workspace-root/clone.cmd.ts +++ b/scopes/workspace/workspace-root/clone.cmd.ts @@ -1,6 +1,6 @@ import path from 'path'; import type { Command, CommandOptions } from '@teambit/cli'; -import { formatHint, formatSuccessSummary, formatWarningSummary } from '@teambit/cli'; +import { formatHint, formatSuccessSummary, formatWarningSummary, joinSections } from '@teambit/cli'; import type { CloneResult } from './clone'; import type { WorkspaceRootMain } from './workspace-root.main.runtime'; @@ -72,5 +72,5 @@ export function formatCloneResult(result: CloneResult, relativeDir: string): str ) : ''; const next = relativeDir === '.' ? '' : formatHint(`cd ${relativeDir}`); - return [summary, lane, missing, installation, next].filter(Boolean).join('\n'); + return joinSections([summary, lane, missing, installation, next]); } diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index 626656d1faf1..1bd5a5504048 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -1,7 +1,8 @@ import { expect } from 'chai'; +import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { resolveComponentDir } from './clone'; +import { ensureEmptyDir, resolveComponentDir } from './clone'; describe('resolveComponentDir', () => { const workspacePath = path.resolve(os.tmpdir(), 'ws'); @@ -27,4 +28,53 @@ describe('resolveComponentDir', () => { const resolve = () => resolveComponentDir(workspacePath, { id: 'a' }); expect(resolve).to.throw('not a directory inside the workspace'); }); + it('should accept a directory whose name starts with dots, it is not a way out', () => { + expect(resolveComponentDir(workspacePath, { id: 'a', rootDir: '..cache' })).to.equal( + path.join(workspacePath, '..cache') + ); + }); +}); + +describe('ensureEmptyDir', () => { + const expectToReject = async (dir: string, message: string) => { + try { + await ensureEmptyDir(dir); + } catch (err: any) { + expect(err.message).to.have.string(message); + return; + } + throw new Error(`expected ensureEmptyDir("${dir}") to throw "${message}"`); + }; + let tmpDir: string; + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bit-clone-')); + }); + afterEach(async () => { + await fs.remove(tmpDir); + }); + it('should create a directory that does not exist and say so, a failed clone removes it', async () => { + const dirPath = path.join(tmpDir, 'new-ws'); + expect(await ensureEmptyDir(dirPath)).to.be.true; + expect(await fs.pathExists(dirPath)).to.be.true; + }); + it('should accept an existing empty directory without claiming it created it', async () => { + expect(await ensureEmptyDir(tmpDir)).to.be.false; + }); + it('should refuse a directory that is not empty', async () => { + await fs.outputFile(path.join(tmpDir, 'file.txt'), 'x'); + await expectToReject(tmpDir, 'the directory is not empty'); + }); + it('should refuse a file', async () => { + const filePath = path.join(tmpDir, 'file.txt'); + await fs.outputFile(filePath, 'x'); + await expectToReject(filePath, 'it is not a directory'); + }); + it('should refuse a symbolic link to an empty directory, the workspace would be written through it', async () => { + // and the cleanup of a failed clone would then empty whatever it points at + const target = path.join(tmpDir, 'elsewhere'); + const link = path.join(tmpDir, 'link'); + await fs.ensureDir(target); + await fs.symlink(target, link); + await expectToReject(link, 'it is a symbolic link'); + }); }); diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 9a527412885d..931326a0e0da 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -1,4 +1,5 @@ import path from 'path'; +import type { Stats } from 'fs-extra'; import fs from 'fs-extra'; import { BitError } from '@teambit/bit-error'; import type { ComponentID } from '@teambit/component-id'; @@ -276,7 +277,9 @@ export async function cloneWorkspace( export function resolveComponentDir(workspacePath: string, entry: VersionedBitmapEntry): string { const target = entry.rootDir ? path.resolve(workspacePath, entry.rootDir) : undefined; const relative = target ? path.relative(workspacePath, target) : undefined; - if (!target || !relative || relative.startsWith('..') || path.isAbsolute(relative)) { + // a leading ".." is only a way out when it is the whole segment: a directory may be named "..cache" + const climbsOut = relative === '..' || relative?.startsWith(`..${path.sep}`); + if (!target || !relative || climbsOut || path.isAbsolute(relative)) { throw new BitError( `unable to clone, the root component lists "${entry.id}" at "${entry.rootDir}", which is not a directory inside the workspace` ); @@ -285,16 +288,35 @@ export function resolveComponentDir(workspacePath: string, entry: VersionedBitma } /** + * the workspace is written into this directory and a failed clone empties it again, so it has to be + * the directory it appears to be: a symbolic link would put the workspace, and then the cleanup, + * wherever it points. the same rule the writer applies to the files of a root (see + * throwForSymlinksInTheWay). + * * @returns whether the directory was created here, so a failed clone knows to remove it */ -async function ensureEmptyDir(dirPath: string): Promise { - if (!(await fs.pathExists(dirPath))) { +export async function ensureEmptyDir(dirPath: string): Promise { + const stat = await lstatIfExists(dirPath); + if (!stat) { await fs.ensureDir(dirPath); return true; } - const stat = await fs.stat(dirPath); + if (stat.isSymbolicLink()) { + throw new BitError( + `unable to clone into "${dirPath}", it is a symbolic link and the workspace would be written through it` + ); + } if (!stat.isDirectory()) throw new BitError(`unable to clone into "${dirPath}", it is not a directory`); const entries = await fs.readdir(dirPath); if (entries.length) throw new BitError(`unable to clone into "${dirPath}", the directory is not empty`); return false; } + +async function lstatIfExists(dirPath: string): Promise { + try { + return await fs.lstat(dirPath); + } catch (err: any) { + if (err.code === 'ENOENT') return undefined; + throw err; + } +} From f51ca92ccc02f06f7c46a337ab066b9815821247 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 17:11:10 -0400 Subject: [PATCH 049/102] chore(deps): refresh the lockfile after the merge --- pnpm-lock.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb8bb0602022..b8862d34cbdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13921,7 +13921,7 @@ importers: version: file:scopes/toolbox/url/query-string '@teambit/tracker': specifier: workspace:* - version: file:scopes/component/tracker(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + version: file:scopes/component/tracker(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/ts-server': specifier: workspace:* version: file:scopes/typescript/ts-server @@ -14009,6 +14009,9 @@ importers: '@teambit/workspace-config-files': specifier: workspace:* version: file:scopes/workspace/workspace-config-files(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/workspace-root': + specifier: workspace:* + version: file:scopes/workspace/workspace-root(chai@4.3.0)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/workspace.aspect-docs.variants': specifier: workspace:* version: file:scopes/workspace/aspect-docs/variants(@teambit/documenter.theme.theme-compositions@4.1.5)(@teambit/mdx.ui.mdx-layout@1.0.14)(react@19.2.7) @@ -85119,7 +85122,6 @@ snapshots: '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.consumer-component': file:components/legacy/consumer-component(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.extension-data': file:components/legacy/extension-data - '@teambit/legacy.scope': file:components/legacy/scope(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.utils': file:components/legacy/utils '@teambit/scope.remotes': file:components/scope/remotes(graphql@15.8.0) '@testing-library/react': 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) @@ -85146,7 +85148,6 @@ snapshots: '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.consumer-component': file:components/legacy/consumer-component(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.extension-data': file:components/legacy/extension-data - '@teambit/legacy.scope': file:components/legacy/scope(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.utils': file:components/legacy/utils '@teambit/scope.remotes': file:components/scope/remotes(graphql@15.8.0) '@testing-library/react': 14.3.1(@types/react@19.2.18)(react-dom@19.2.7)(react@19.2.7) From 16122c64530d406cbcb2a533d498f570ae3703b7 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 17:23:50 -0400 Subject: [PATCH 050/102] fix(component-writer): refuse to move a component out of the workspace root, it would take the workspace with it --- e2e/harmony/add-harmony.e2e.ts | 21 +++++++------------ .../component-writer.main.runtime.ts | 8 +++++++ scopes/workspace/workspace-root/clone.spec.ts | 15 +++++++++++++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 53bf1c5e0c96..f8ecbc6a8e34 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -327,6 +327,14 @@ describe('add command on Harmony', function () { // the exported .bitmap lists comp1. the restored workspace must not inherit that entry. expect(helper.bitMap.read()).to.not.have.property('comp1'); }); + it('should refuse to move it out of the workspace root, which would take the workspace with it', () => { + // the mover schedules the removal of the directory the component is leaving, and here that + // directory is the workspace tree. --override, which waives the other guards, gets this far + const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path some-dir --override'); + expect(cmd).to.throw('tracked at the workspace root'); + expect(path.join(helper.scopes.localPath, 'workspace.jsonc')).to.be.a.file(); + expect(path.join(helper.scopes.localPath, '.bitmap')).to.be.a.file(); + }); it('should refuse a second import without --override once the user changed a root file, not overwrite it', () => { // a changed root file makes the root a modified component, and the importer refuses modified // components before anything is written. the writer's same-directory shortcut never sees it. @@ -566,19 +574,6 @@ describe('add command on Harmony', function () { expect(status.modifiedComponents).to.have.lengthOf(0); expect(status.newComponents).to.have.lengthOf(0); }); - it('should refuse to run inside a workspace', () => { - const cmd = () => helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root other -x`); - expect(cmd).to.throw('inside the workspace'); - }); - it('should refuse a directory that is not empty', () => { - // run from the parent of the workspaces, which is not a workspace - const cmd = () => - helper.command.runCmd( - `bit clone ${helper.scopes.remote}/ws-root ${helper.scopes.localPath} -x`, - helper.scopes.e2eDir - ); - expect(cmd).to.throw('not empty'); - }); it('should default the directory to the component name', () => { const clonePath = path.join(helper.scopes.e2eDir, 'ws-root'); fs.removeSync(clonePath); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index a6f0336dbc3b..7bf6ff0a0a58 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -335,6 +335,14 @@ to move all component files to a different directory, run bit remove and then bi // @ts-ignore this.writeToPath is set at this point const absoluteWriteToPath = path.resolve(writeToPath); // don't use consumer.toAbsolutePath, it might be an inner dir if (relativeWrittenPath && absoluteWrittenPath !== absoluteWriteToPath) { + // a component tracked at the workspace root is the workspace. moving it schedules the removal + // of the directory it leaves, which there is the workspace tree (see moveExistingComponent) + if (componentMap.rootDir === WORKSPACE_ROOT_DIR) { + throw new BitError( + `unable to write "${component.id.toString()}" to "${writeToPath}", it is tracked at the workspace root, which is the workspace itself. +run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace should stop tracking its root` + ); + } this.mover.moveExistingComponent(component, absoluteWrittenPath, absoluteWriteToPath); } }); diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index 1bd5a5504048..c098c7bc0d36 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -3,6 +3,7 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { ensureEmptyDir, resolveComponentDir } from './clone'; +import { WorkspaceRootMain } from './workspace-root.main.runtime'; describe('resolveComponentDir', () => { const workspacePath = path.resolve(os.tmpdir(), 'ws'); @@ -78,3 +79,17 @@ describe('ensureEmptyDir', () => { await expectToReject(link, 'it is a symbolic link'); }); }); + +describe('clone from a workspace', () => { + it('should refuse, a clone is a new workspace and this one is already loaded', async () => { + // the check comes before anything is fetched or written, so no workspace is needed to reach it + const workspaceRoot = new WorkspaceRootMain({ path: '/ws' } as any); + try { + await workspaceRoot.clone('my-scope/ws-root', undefined, {}); + } catch (err: any) { + expect(err.message).to.have.string('unable to clone inside the workspace at "/ws"'); + return; + } + throw new Error('expected clone to throw'); + }); +}); From 65c74ee2664e6d1314b7aa47bff4c8054e961ad7 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 17:32:10 -0400 Subject: [PATCH 051/102] fix(snapping): record the workspace root only on components the workspace tracks, carry trackAllFiles through init --- scopes/component/snapping/version-maker.ts | 9 +++++++-- scopes/harmony/host-initializer/create-consumer.ts | 1 + scopes/workspace/workspace-root/clone.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 58b19dc2bf74..3a1c92a8c920 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -773,13 +773,18 @@ export class VersionMaker { * see WorkspaceRootMain. */ private recordWorkspaceRoot() { - if (!this.consumer) return; - const rootMap = findWorkspaceRootMap(this.consumer.bitMap); + const consumer = this.consumer; + if (!consumer) return; + const rootMap = findWorkspaceRootMap(consumer.bitMap); if (!rootMap) return; const rootInBatch = this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap.id; this.allComponentsToTag.forEach((component) => { if (component.id.isEqualWithoutVersion(rootMap.id)) return; + // hidden lane entries (lane.updateDependents) cascade into the batch from the scope rather than + // from the workspace, so they were not snapped in this root. absence from .bitmap is how they + // are told apart elsewhere in this file as well + if (!consumer.bitMap.getComponentIfExist(component.id, { ignoreVersion: true })) return; writeWorkspaceRoot(component.extensions, rootId); }); } diff --git a/scopes/harmony/host-initializer/create-consumer.ts b/scopes/harmony/host-initializer/create-consumer.ts index 1babad25f5cb..f2a6e0aba045 100644 --- a/scopes/harmony/host-initializer/create-consumer.ts +++ b/scopes/harmony/host-initializer/create-consumer.ts @@ -34,6 +34,7 @@ export async function createConsumer( defaultScope: workspaceExtensionProps.defaultScope, defaultDirectory: workspaceExtensionProps.defaultDirectory, components: workspaceExtensionProps.components, + trackAllFiles: workspaceExtensionProps.trackAllFiles, }), // remove empty values 'teambit.dependencies/dependency-resolver': workspaceExtensionProps.externalPackageManager ? { externalPackageManager: workspaceExtensionProps.externalPackageManager } diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 931326a0e0da..0a73fd5a8eba 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -20,7 +20,7 @@ import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; import { isWorkspaceRootComponent } from './workspace-root-data'; -export type LoadBit = (path?: string) => Promise; +export type LoadBit = (workspacePath?: string) => Promise; export type CloneOptions = { /** From be52daff7c72ab2c04c271e11bd62a47cda6717e Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 17:35:53 -0400 Subject: [PATCH 052/102] fix(workspace-root): restore the caller's working directory after a successful clone --- scopes/workspace/workspace-root/clone.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 0a73fd5a8eba..65ef2fb061b8 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -259,12 +259,16 @@ export async function cloneWorkspace( const harmony = await loadBit(workspacePath); return await new WorkspaceCloner(harmony, workspacePath).clone(rootId, options); } catch (err) { - // the caller is left where it was, and nothing half-made is left behind: the directory was empty - // or absent to begin with + // leave the directory before removing it, and leave nothing half-made behind: it was empty or + // absent to begin with process.chdir(originalCwd); if (createdDir) await fs.remove(workspacePath); else await fs.emptyDir(workspacePath); throw err; + } finally { + // the clone itself runs with the new workspace as the cwd, its install included. the caller, which + // may be a program that goes on to do other things, keeps the directory it was in. + process.chdir(originalCwd); } } From dfda122d317affa0f4ea222243d343ac7953bcd4 Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 19:02:09 -0400 Subject: [PATCH 053/102] fix(watcher): ignore every file bit generates, not only package.json, so a tracked root stops reporting them --- scopes/workspace/watcher/watcher.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index db1129b4e251..a22dfaed1e32 100644 --- a/scopes/workspace/watcher/watcher.ts +++ b/scopes/workspace/watcher/watcher.ts @@ -3,7 +3,7 @@ import fs from 'fs-extra'; import { dirname, basename, join, relative } from 'path'; import { compact, difference, partition } from 'lodash'; import type { ComponentID, ComponentIdList } from '@teambit/component-id'; -import { BIT_MAP, WORKSPACE_JSONC } from '@teambit/legacy.constants'; +import { BIT_GENERATED_IGNORE_LIST, BIT_MAP, IGNORE_ROOT_ONLY_LIST, WORKSPACE_JSONC } from '@teambit/legacy.constants'; import type { Consumer } from '@teambit/legacy.consumer'; import { logger } from '@teambit/legacy.logger'; import type { PathOsBasedAbsolute } from '@teambit/legacy.utils'; @@ -168,14 +168,10 @@ export class Watcher { return this.workspace.consumer; } - /** - * the paths the watcher never reports, for either backend. package.json is bit's own output, unless - * the workspace tracks every file (trackAllFiles): then it is component source and its edits count. - */ + /** the paths the watcher never reports, for either backend. see watchIgnorePatterns */ private getIgnorePatterns(): string[] { const relScopePath = pathNormalizeToLinux(relative(this.workspace.path, this.workspace.scope.path)); - const manifests = this.consumer.config.trackAllFiles ? [] : ['**/package.json']; - return ['**/node_modules/**', ...manifests, `**/${relScopePath}/**`]; + return watchIgnorePatterns(relScopePath, this.consumer.config.trackAllFiles); } /** @@ -1143,3 +1139,21 @@ export class Watcher { } } } + +/** + * the paths the watcher never reports, for either backend. the files bit generates are its own output + * rather than component source, unless the workspace tracks every file (trackAllFiles), and a + * component never lists them - so reporting an edit of one only produces "inside the component but + * configured to be ignored". a workspace-root component owns the whole tree, which makes the + * manifests and configs at the workspace root the common case. the root-only names stay unprefixed on + * purpose: they are ignored where a component's rootDir is, and here that is the workspace root. + */ +export function watchIgnorePatterns(relScopePath: string, trackAllFiles?: boolean): string[] { + const generated = trackAllFiles + ? [] + : [ + ...BIT_GENERATED_IGNORE_LIST.map((pattern) => (pattern.includes('/') ? pattern : `**/${pattern}`)), + ...IGNORE_ROOT_ONLY_LIST, + ]; + return ['**/node_modules/**', ...generated, `**/${relScopePath}/**`]; +} From 3578fa44db0124fb8089488eaed7b73fcc19c83d Mon Sep 17 00:00:00 2001 From: David First Date: Wed, 16 Sep 2026 19:02:24 -0400 Subject: [PATCH 054/102] test(watcher): cover the ignore patterns a tracked workspace root depends on --- scopes/workspace/watcher/watcher.spec.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 scopes/workspace/watcher/watcher.spec.ts diff --git a/scopes/workspace/watcher/watcher.spec.ts b/scopes/workspace/watcher/watcher.spec.ts new file mode 100644 index 000000000000..72ab59cd0d86 --- /dev/null +++ b/scopes/workspace/watcher/watcher.spec.ts @@ -0,0 +1,20 @@ +import { expect } from 'chai'; +import { watchIgnorePatterns } from './watcher'; + +describe('watchIgnorePatterns', () => { + it('should ignore the files bit generates, which no component lists', () => { + // otherwise every save of one is reported as a change to a component "configured to be ignored". + // a workspace-root component owns the whole tree, so the ones at the workspace root are the case + const patterns = watchIgnorePatterns('.bit'); + expect(patterns).to.include.members(['**/package.json', '**/yarn.lock', '**/package-lock.json']); + expect(patterns).to.include('tsconfig.json'); + }); + it('should report them in a workspace that tracks every file, there they are component source', () => { + const patterns = watchIgnorePatterns('.bit', true); + expect(patterns).to.not.include('**/package.json'); + expect(patterns).to.not.include('tsconfig.json'); + }); + it('should always ignore node_modules and the local scope', () => { + expect(watchIgnorePatterns('.bit', true)).to.include.members(['**/node_modules/**', '**/.bit/**']); + }); +}); From b46e2532c7e45029bb9aed7832d2043efa2815d0 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 09:40:49 -0400 Subject: [PATCH 055/102] fix(bit-map): apply nested ignore files deepest-last, and skip the symlink check when nothing is written --- components/legacy/bit-map/bit-map.spec.ts | 23 ++++++++++++++++++- components/legacy/bit-map/component-map.ts | 6 ++++- .../component-writer.main.runtime.ts | 3 ++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index a2656e79094a..f25b8ae0024b 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import fs from 'fs-extra'; +import ignore from 'ignore'; import os from 'os'; import * as path from 'path'; import { ComponentID } from '@teambit/component-id'; @@ -11,7 +12,7 @@ import { normalizeBitmapContentForVersioning, readVersionedBitmapEntries, } from './bit-map'; -import { WORKSPACE_ROOT_DIR } from './component-map'; +import { filterByIgnoreFiles, WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; const getBitmapInstance = async () => { @@ -337,4 +338,24 @@ ${JSON.stringify( expect(resolve('comp1/new-file.js')).to.equal(compId.toString()); }); }); + + describe('filterByIgnoreFiles with ignore files below the workspace root', () => { + let tmpDir: string; + const paths = ['docs/sub/.gitignore', 'docs/.gitignore', 'docs/a.log', 'docs/sub/keep.log']; + before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bit-ignore-')); + await fs.outputFile(path.join(tmpDir, 'docs/.gitignore'), '*.log\n'); + await fs.outputFile(path.join(tmpDir, 'docs/sub/.gitignore'), '!keep.log\n'); + }); + after(async () => { + await fs.remove(tmpDir); + }); + it('should let a deeper rule decide, whatever order the scan walked them in', async () => { + // the paths list the deeper ignore file first, which is what the scan may hand over. read in + // that order, the rule above would win and take the file the one below re-included + const filtered = await filterByIgnoreFiles(WORKSPACE_ROOT_DIR, tmpDir, ignore(), paths); + expect(filtered).to.include('docs/sub/keep.log'); + expect(filtered).to.not.include('docs/a.log'); + }); + }); }); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 6801dcd0f4fd..67a4c660f3dc 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -174,8 +174,12 @@ async function getNestedIgnorePatterns( if (name === BIT_IGNORE || !ignoreFileByDir.has(fileDir)) ignoreFileByDir.set(fileDir, name); }); if (!ignoreFileByDir.size) return []; + // git reads the ignore files of a path from the shallowest down, so a deeper rule decides over the + // one above it. the scan hands them over in whatever order it walked, so they are ordered here + // rather than by that: the patterns are applied in the order they are returned. + const byDepth = Array.from(ignoreFileByDir).sort(([dirA], [dirB]) => dirA.split('/').length - dirB.split('/').length); const patternsPerDir = await Promise.all( - Array.from(ignoreFileByDir, async ([fileDir, name]) => { + byDepth.map(async ([fileDir, name]) => { const absoluteDir = path.join(consumerPath, fileDir); const patterns = name === BIT_IGNORE ? await getBitIgnoreFile(absoluteDir) : await getGitIgnoreFile(absoluteDir); return patterns.map((pattern) => rebaseIgnorePattern(pattern, fileDir)); diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 7bf6ff0a0a58..4e573c8ee620 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -292,7 +292,8 @@ export class ComponentWriterMain { // a checkout or a re-import, so the guards of the root run for it as well if (componentRootDir === WORKSPACE_ROOT_DIR || existingComponentMap?.rootDir === WORKSPACE_ROOT_DIR) { this.throwForNonWorkspaceRootComponent(component); - this.throwForSymlinksInTheWay(component); + // the symlink rule is about where a write lands, and --track-only writes nothing (skipWritingToFs) + if (!opts.skipWritingToFs) this.throwForSymlinksInTheWay(component); } // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. From d0a98009cb7ac4c4f8a8d3a3e04fd430938abb59 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 09:59:06 -0400 Subject: [PATCH 056/102] fix(snapping): keep a versions file from dictating the auto-added root's version a versions file names the components being tagged. the workspace root joins the batch on its own, so without a DEFAULT the tag crashed on missing tag-data, and with one the root took that version instead of its patch bump. it now follows the file only when the file names it. Co-Authored-By: Claude Opus 5 (1M context) --- .../snapping/version-file-parser.spec.ts | 32 +++++++++++++++++++ .../component/snapping/version-file-parser.ts | 11 ++++++- scopes/component/snapping/version-maker.ts | 17 ++++++---- scopes/workspace/workspace-root/clone.ts | 5 +-- 4 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 scopes/component/snapping/version-file-parser.spec.ts diff --git a/scopes/component/snapping/version-file-parser.spec.ts b/scopes/component/snapping/version-file-parser.spec.ts new file mode 100644 index 000000000000..39b35bff5c52 --- /dev/null +++ b/scopes/component/snapping/version-file-parser.spec.ts @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import { ComponentID, ComponentIdList } from '@teambit/component-id'; +import { VersionFileParser } from './version-file-parser'; + +describe('VersionFileParser', () => { + const member = ComponentID.fromString('my-org.my-scope/my-comp'); + const root = ComponentID.fromString('my-org.my-scope/my-root'); + const ids = ComponentIdList.fromArray([member, root]); + const versionOf = (results: ReturnType, id: ComponentID) => + results.find((result) => result.componentId.isEqualWithoutVersion(id))?.versionToTag; + + describe('a component excluded from DEFAULT, the way the workspace root is', () => { + it('should not get a version from the DEFAULT line', () => { + const parser = new VersionFileParser(ids, root); + const results = parser.parseVersionsContent('DEFAULT: 1.0.0'); + expect(versionOf(results, member)).to.equal('1.0.0'); + expect(versionOf(results, root)).to.be.undefined; + }); + it('should get the version when the file names it, DEFAULT or not', () => { + const parser = new VersionFileParser(ids, root); + const results = parser.parseVersionsContent('DEFAULT: 1.0.0\nmy-org.my-scope/my-root: 2.0.0'); + expect(versionOf(results, member)).to.equal('1.0.0'); + expect(versionOf(results, root)).to.equal('2.0.0'); + }); + }); + + it('should give every component the DEFAULT when nothing is excluded', () => { + const results = new VersionFileParser(ids).parseVersionsContent('DEFAULT: 1.0.0'); + expect(versionOf(results, member)).to.equal('1.0.0'); + expect(versionOf(results, root)).to.equal('1.0.0'); + }); +}); diff --git a/scopes/component/snapping/version-file-parser.ts b/scopes/component/snapping/version-file-parser.ts index 9a49a2d6b6d4..d06e82159a39 100644 --- a/scopes/component/snapping/version-file-parser.ts +++ b/scopes/component/snapping/version-file-parser.ts @@ -4,7 +4,15 @@ import { BitError } from '@teambit/bit-error'; import type { TagDataPerComp } from './snapping.main.runtime'; export class VersionFileParser { - constructor(private componentsToTag: ComponentIdList) {} + constructor( + private componentsToTag: ComponentIdList, + /** + * a component that joined the batch on its own - the workspace root - rather than being tagged. + * the file is written for the components being tagged, so DEFAULT does not reach it. naming it + * on a line of its own still does. + */ + private excludedFromDefault?: ComponentID + ) {} async parseVersionsFile(filePath: string): Promise { if (!(await fs.pathExists(filePath))) { @@ -84,6 +92,7 @@ export class VersionFileParser { const specifiedIds = new Set(results.map((r) => r.componentId.toStringWithoutVersion())); for (const componentId of this.componentsToTag) { + if (this.excludedFromDefault?.isEqualWithoutVersion(componentId)) continue; if (!specifiedIds.has(componentId.toStringWithoutVersion())) { let prereleaseId: string | undefined; if (defaultVersion.includes('-')) { diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 3a1c92a8c920..fdf87040bd48 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -425,7 +425,7 @@ export class VersionMaker { if (!versionsFile) return; const allComponentsToTag = ComponentIdList.fromArray([...idsToTag, ...autoTagIds]); - const versionFileParser = new VersionFileParser(allComponentsToTag); + const versionFileParser = new VersionFileParser(allComponentsToTag, this.params.autoAddedWorkspaceRoot); const tagDataFromFile = await versionFileParser.parseVersionsFile(versionsFile); this.params.tagDataPerComp = tagDataFromFile; } @@ -522,9 +522,18 @@ export class VersionMaker { const isAutoTag = autoTagIds.hasWithoutVersion(componentToTag.id); const modelComponent = await this.legacyScope.sources.findOrAddComponent(componentToTag); const nextVersion = componentToTag.componentMap?.nextVersion?.version; + const isAutoAddedRoot = Boolean(this.params.autoAddedWorkspaceRoot?.isEqualWithoutVersion(componentToTag.id)); + // the root joined the batch on its own. a version given for the members - `--ver`, the id, or + // a versions-file DEFAULT - is not meant for it, so it is bumped the way an auto-tagged + // dependent is. + const bumpRootAsPatch = () => + soft ? 'patch' : modelComponent.getVersionToAdd('patch', undefined, incrementBy, preReleaseId); const getNewVersion = (): string => { if (tagDataPerComp) { const tagData = tagDataPerComp.find((t) => t.componentId.isEqualWithoutVersion(componentToTag.id)); + // a versions file names the components being tagged. it covers the root only when it names + // it, see VersionFileParser - otherwise the root is still bumped on its own. + if (!tagData && isAutoAddedRoot) return bumpRootAsPatch(); if (!tagData) throw new Error(`tag-data is missing for ${componentToTag.id.toStringWithoutVersion()}`); if (!tagData.versionToTag) throw new Error(`tag-data.TagResults is missing for ${componentToTag.id.toStringWithoutVersion()}`); @@ -560,11 +569,7 @@ export class VersionMaker { } return soft ? 'patch' : modelComponent.getVersionToAdd('patch', undefined, incrementBy, preReleaseId); } - if (this.params.autoAddedWorkspaceRoot?.isEqualWithoutVersion(componentToTag.id)) { - // the root joined the batch on its own. a version given for the members - `--ver`, or on the - // id - is not meant for it, so it is bumped the way an auto-tagged dependent is. - return soft ? 'patch' : modelComponent.getVersionToAdd('patch', undefined, incrementBy, preReleaseId); - } + if (isAutoAddedRoot) return bumpRootAsPatch(); const versionByEnteredId = this.getVersionByEnteredId(this.ids, componentToTag, modelComponent); return soft ? versionByEnteredId || exactVersion || (releaseType as string) diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 65ef2fb061b8..19e22a1beced 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -232,10 +232,11 @@ export async function cloneWorkspace( ): Promise { const workspacePath = path.resolve(dir || rootId.name); const createdDir = await ensureEmptyDir(workspacePath); - // the code paths below, from the workspace init to the install, take the workspace from the cwd const originalCwd = process.cwd(); - process.chdir(workspacePath); try { + // the code paths below, from the workspace init to the install, take the workspace from the cwd. + // inside the try, so that a directory made here is removed even when entering it is what failed + process.chdir(workspacePath); // only workspace.jsonc, .bitmap and the scope dir. the root files come from the component, and // only what the component versions belongs at the root: no package.json, agent file or mcp config await HostInitializerMain.init( From 424d8e885c9eea27dbfb0273f2519dcef9963cbc Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 10:29:56 -0400 Subject: [PATCH 057/102] test(tracker): cover the workspace-root add cases in a spec instead of e2e the re-add/double-add and main-file-guard blocks only needed "bit add" and the resulting .bitmap, so one harmony load stands in for a process per command. add-harmony.e2e.ts: 170s -> 131s, the 7 cases run in ~1s. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/harmony/add-harmony.e2e.ts | 47 ------ .../tracker/track-workspace-root.spec.ts | 146 ++++++++++++++++++ 2 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 scopes/component/tracker/track-workspace-root.spec.ts diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index f8ecbc6a8e34..a36455c0574b 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -224,53 +224,6 @@ describe('add command on Harmony', function () { expect(path.join(helper.scopes.localPath, packageDir, 'index.js')).to.be.a.path(); }); }); - describe('re-adding and double-adding the workspace root', () => { - before(() => { - helper.scopeHelper.reInitWorkspace(); - helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'README.md' }); - }); - it('should take the main file given explicitly over the default', () => { - expect(helper.bitMap.read()['ws-root'].mainFile).to.equal('README.md'); - }); - it('should allow re-adding the same component', () => { - helper.fs.outputFile('extra.md', 'extra\n'); - expect(() => helper.command.addComponent('.', { i: 'ws-root' })).to.not.throw(); - }); - it('should allow re-adding it without repeating its name, and keep its main file', () => { - expect(() => helper.command.addComponent('.')).to.not.throw(); - expect(Object.keys(helper.bitMap.readComponentsMapOnly())).to.deep.equal(['ws-root']); - expect(helper.bitMap.read()['ws-root'].mainFile).to.equal('README.md'); - }); - it('should reject a second component claiming the workspace root', () => { - const cmd = () => helper.command.addComponent('.', { i: 'another-root' }); - expect(cmd).to.throw('already tracked by'); - }); - it('should pick up dotfiles at add time, not only on the next rescan', () => { - helper.fs.outputFile('.npmrc', 'registry=https://example.com\n'); - const output = helper.command.addComponent('.', { i: 'ws-root' }); - expect(output).to.have.string('.npmrc'); - // its auto-generated banner must not get it dropped, the rescan tracks it - expect(output).to.have.string('.bitmap'); - }); - }); - describe('adding a nested component that holds the main file of the workspace root', () => { - before(() => { - helper.scopeHelper.reInitWorkspace(); - helper.fs.outputFile('packages/comp1/index.js', 'module.exports = () => "comp1";\n'); - helper.command.addComponent('.', { i: 'ws-root', m: 'packages/comp1/index.js' }); - }); - it('should refuse, because the root would fail to load without its main file', () => { - const cmd = () => helper.command.addComponent('packages/comp1', { i: 'comp1' }); - expect(cmd).to.throw('main file of the workspace-root component'); - }); - it('should refuse even when the nested component ignores that file, its directory is what the root loses', () => { - helper.fs.outputFile('packages/comp1/.bitignore', 'index.js\n'); - helper.fs.outputFile('packages/comp1/other.js', ''); - const cmd = () => helper.command.addComponent('packages/comp1', { i: 'comp1' }); - expect(cmd).to.throw('main file of the workspace-root component'); - }); - }); describe('writing the workspace-root component to the filesystem', () => { let firstSnap: string; before(() => { diff --git a/scopes/component/tracker/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts new file mode 100644 index 000000000000..13d7022771a9 --- /dev/null +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -0,0 +1,146 @@ +import { expect } from 'chai'; +import fs from 'fs-extra'; +import path from 'path'; +import { loadManyAspects } from '@teambit/harmony.testing.load-aspect'; +import type { WorkspaceData } from '@teambit/workspace.testing.mock-workspace'; +import { mockWorkspace, destroyWorkspace } from '@teambit/workspace.testing.mock-workspace'; +import type { Workspace } from '@teambit/workspace'; +import { WorkspaceAspect } from '@teambit/workspace'; +import { TrackerAspect } from './tracker.aspect'; +import type { TrackerMain } from './tracker.main.runtime'; + +/** + * tracking a component at the workspace root (rootDir "."). these cases only need `bit add` and the + * resulting .bitmap, so they run here rather than as e2e - one harmony load stands in for a process + * per command. the flows that need a remote (export, import onto ".", clone) stay in + * e2e/harmony/add-harmony.e2e.ts. + */ +describe('tracking the workspace root', function () { + this.timeout(0); + + type Tracked = { workspaceData: WorkspaceData; workspacePath: string; workspace: Workspace; tracker: TrackerMain }; + + async function setupWorkspace(files: Record): Promise { + const workspaceData = mockWorkspace(); + const { workspacePath } = workspaceData; + Object.entries(files).forEach(([filePath, content]) => + fs.outputFileSync(path.join(workspacePath, filePath), content) + ); + const harmony = await loadManyAspects([WorkspaceAspect, TrackerAspect], workspacePath); + return { + workspaceData, + workspacePath, + workspace: harmony.get(WorkspaceAspect.id), + tracker: harmony.get(TrackerAspect.id), + }; + } + + /** chai has no async throw assertion that also matches a message */ + async function expectToReject(fn: () => Promise, messagePart: string) { + try { + await fn(); + } catch (err: any) { + expect(err.message).to.have.string(messagePart); + return; + } + throw new Error(`expected to reject with "${messagePart}", but it resolved`); + } + + const inWs = (tracked: Tracked, relPath: string) => path.join(tracked.workspacePath, relPath); + + const rootDirOf = (tracked: Tracked, name: string) => + tracked.workspace.bitMap.getBitmapEntry(tracked.workspace.consumer.getParsedId(name), { ignoreVersion: true }) + .rootDir; + const mainFileOf = (tracked: Tracked, name: string) => + tracked.workspace.bitMap.getBitmapEntry(tracked.workspace.consumer.getParsedId(name), { ignoreVersion: true }) + .mainFile; + + describe('re-adding and double-adding the workspace root', () => { + let tracked: Tracked; + before(async () => { + tracked = await setupWorkspace({ 'README.md': '# workspace root\n' }); + await tracked.tracker.addForCLI({ + componentPaths: [tracked.workspacePath], + id: 'ws-root', + main: inWs(tracked, 'README.md'), + override: false, + }); + }); + after(async () => { + await destroyWorkspace(tracked.workspaceData); + }); + it('should take the main file given explicitly over the default', () => { + expect(mainFileOf(tracked, 'ws-root')).to.equal('README.md'); + }); + it('should allow re-adding the same component', async () => { + fs.outputFileSync(path.join(tracked.workspacePath, 'extra.md'), 'extra\n'); + await tracked.tracker.addForCLI({ componentPaths: [tracked.workspacePath], id: 'ws-root', override: false }); + expect(rootDirOf(tracked, 'ws-root')).to.equal('.'); + }); + it('should allow re-adding it without repeating its name, and keep its main file', async () => { + await tracked.tracker.addForCLI({ componentPaths: [tracked.workspacePath], override: false }); + // no second component was created out of the unnamed re-add + expect(tracked.workspace.bitMap.getAllRootDirs()).to.deep.equal(['.']); + expect(mainFileOf(tracked, 'ws-root')).to.equal('README.md'); + }); + it('should reject a second component claiming the workspace root', async () => { + await expectToReject( + () => + tracked.tracker.addForCLI({ componentPaths: [tracked.workspacePath], id: 'another-root', override: false }), + 'already tracked by' + ); + }); + it('should pick up dotfiles at add time, not only on the next rescan', async () => { + fs.outputFileSync(path.join(tracked.workspacePath, '.npmrc'), 'registry=https://example.com\n'); + const results = await tracked.tracker.addForCLI({ + componentPaths: [tracked.workspacePath], + id: 'ws-root', + override: false, + }); + const files = results.addedComponents[0].files.map((file) => file.relativePath); + expect(files).to.include('.npmrc'); + // its auto-generated banner must not get it dropped, the rescan tracks it + expect(files).to.include('.bitmap'); + }); + }); + + describe('adding a nested component that holds the main file of the workspace root', () => { + let tracked: Tracked; + before(async () => { + tracked = await setupWorkspace({ 'packages/comp1/index.js': 'module.exports = () => "comp1";\n' }); + await tracked.tracker.addForCLI({ + componentPaths: [tracked.workspacePath], + id: 'ws-root', + main: inWs(tracked, 'packages/comp1/index.js'), + override: false, + }); + }); + after(async () => { + await destroyWorkspace(tracked.workspaceData); + }); + it('should refuse, because the root would fail to load without its main file', async () => { + await expectToReject( + () => + tracked.tracker.addForCLI({ + componentPaths: [inWs(tracked, 'packages/comp1')], + id: 'comp1', + override: false, + }), + 'main file of the workspace-root component' + ); + }); + it('should refuse even when the nested component ignores that file, its directory is what the root loses', async () => { + fs.outputFileSync(path.join(tracked.workspacePath, 'packages/comp1/.bitignore'), 'index.js\n'); + fs.outputFileSync(path.join(tracked.workspacePath, 'packages/comp1/other.js'), ''); + await expectToReject( + () => + tracked.tracker.addForCLI({ + componentPaths: [inWs(tracked, 'packages/comp1')], + id: 'comp1', + override: false, + }), + 'main file of the workspace-root component' + ); + }); + }); +}); From 58c550aa0ca13f88b292e2311f6967cfd9597943 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 10:50:16 -0400 Subject: [PATCH 058/102] fix(workspace-root): keep a clone and a root snapshot within their own workspace and lane - refuse to clone into a directory inside an existing workspace. the init took the workspace above it instead of making one, and the clone then wrote its components into that workspace's .bitmap while reporting success. - drop components of other lanes from the versioned .bitmap, and the lane bookkeeping a switch flips. a clone of a root snapped on main was importing lane-only members. - ignore a root of another lane, or a removed one, when looking one up. snapping on a rootless lane was tagging it along. Co-Authored-By: Claude Opus 5 (1M context) --- components/legacy/bit-map/bit-map.spec.ts | 37 +++++++++++++++++++ components/legacy/bit-map/bit-map.ts | 32 ++++++++++++---- e2e/harmony/add-harmony.e2e.ts | 8 ---- scopes/workspace/workspace-root/clone.spec.ts | 17 ++++++++- scopes/workspace/workspace-root/clone.ts | 31 ++++++++++++++-- .../workspace-root-data.spec.ts | 28 +++++++++----- .../workspace-root/workspace-root-data.ts | 8 +++- 7 files changed, 131 insertions(+), 30 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index f25b8ae0024b..7587fff5d750 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -205,6 +205,36 @@ describe('BitMap', function () { it('should be idempotent, otherwise the root component would never converge', () => { expect(normalizeBitmapContentForVersioning(normalized)).to.equal(normalized); }); + describe('a component of another lane, left in the map so a switch back can restore it', () => { + const withLaneComp = JSON.stringify({ + comp1: { name: 'comp1', scope: 'my-scope', rootDir: 'comp1' }, + comp2: { name: 'comp2', scope: 'my-scope', rootDir: 'comp2', isAvailableOnCurrentLane: false }, + comp3: { + name: 'comp3', + scope: 'my-scope', + rootDir: 'comp3', + isAvailableOnCurrentLane: true, + onLanesOnly: true, + }, + }); + let laneParsed: Record; + before(() => { + const result = normalizeBitmapContentForVersioning(withLaneComp); + laneParsed = JSON.parse(result.slice(result.indexOf('{'))); + }); + it('should drop it, a clone of this root would import it from a lane it never asked for', () => { + expect(laneParsed).to.not.have.property('comp2'); + }); + it('should keep the components of this lane', () => { + expect(laneParsed).to.have.property('comp1'); + expect(laneParsed).to.have.property('comp3'); + }); + it('should drop the lane bookkeeping, which a switch and a merge flip', () => { + // otherwise the root is modified by a lane switch the same way the lane key used to do + expect(laneParsed.comp3).to.not.have.property('isAvailableOnCurrentLane'); + expect(laneParsed.comp3).to.not.have.property('onLanesOnly'); + }); + }); }); describe('readVersionedBitmapEntries', () => { const versionedBitmap = `/* THIS IS A BIT-AUTO-GENERATED FILE */ @@ -244,6 +274,13 @@ ${JSON.stringify( it('should return nothing for an empty map', () => { expect(readVersionedBitmapEntries(JSON.stringify({ '$schema-version': '17.0.0' }))).to.deep.equal([]); }); + it('should skip a component of another lane, which a map versioned before they were dropped still carries', () => { + const withLaneComp = JSON.stringify({ + comp1: { name: 'comp1', scope: 'my-scope', rootDir: 'comp1' }, + comp2: { name: 'comp2', scope: 'my-scope', rootDir: 'comp2', isAvailableOnCurrentLane: false }, + }); + expect(readVersionedBitmapEntries(withLaneComp).map((entry) => entry.rootDir)).to.deep.equal(['comp1']); + }); }); describe('a rootDir with one owner', () => { const componentParams = { diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 6c4c088945ed..63f0ecca14ff 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -1124,6 +1124,17 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string Object.keys(parsed).forEach((key) => { const entry = parsed[key]; if (!entry || typeof entry !== 'object') return; + // a component of another lane. it is in the map so a switch back can restore it, but it is not + // part of this workspace as versioned here: a clone of this root would import it from a lane it + // was never asked for. the root snapped on that lane lists it, which is where it belongs. + if (entry.isAvailableOnCurrentLane === false) { + delete parsed[key]; + return; + } + // which lane a component is on, and whether it ever reached main: workspace state that a lane + // switch and a lane merge flip, the same as LANE_KEY above + delete entry.isAvailableOnCurrentLane; + delete entry.onLanesOnly; if (entry.version !== undefined) entry.version = ''; if (entry.scope !== undefined || entry.defaultScope !== undefined) { entry.scope = entry.scope || entry.defaultScope || ''; @@ -1155,14 +1166,19 @@ export function readVersionedBitmapEntries(rawContent: string): VersionedBitmapE const parsed = json.parse(rawContent, undefined, true) as Record | undefined; if (!parsed) return []; BitMap.removeNonComponentFields(parsed); - return Object.keys(parsed) - .filter((key) => parsed[key] && typeof parsed[key] === 'object') - .map((key) => { - const entry = parsed[key]; - const name = entry.name || key; - const scope = entry.scope || entry.defaultScope; - return { id: scope ? `${scope}/${name}` : name, rootDir: entry.rootDir }; - }); + return ( + Object.keys(parsed) + .filter((key) => parsed[key] && typeof parsed[key] === 'object') + // normalizeBitmapContentForVersioning drops these, so only a map versioned before it did can + // still carry one. importing it would bring in a component of a lane this clone never asked for. + .filter((key) => parsed[key].isAvailableOnCurrentLane !== false) + .map((key) => { + const entry = parsed[key]; + const name = entry.name || key; + const scope = entry.scope || entry.defaultScope; + return { id: scope ? `${scope}/${name}` : name, rootDir: entry.rootDir }; + }) + ); } /** diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index a36455c0574b..da82acacb877 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -527,14 +527,6 @@ describe('add command on Harmony', function () { expect(status.modifiedComponents).to.have.lengthOf(0); expect(status.newComponents).to.have.lengthOf(0); }); - it('should default the directory to the component name', () => { - const clonePath = path.join(helper.scopes.e2eDir, 'ws-root'); - fs.removeSync(clonePath); - helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root -x`, helper.scopes.e2eDir); - expect(path.join(clonePath, 'workspace.jsonc')).to.be.a.file(); - expect(path.join(clonePath, 'comp1/index.js')).to.be.a.file(); - fs.removeSync(clonePath); - }); it('should refuse a component that is not a workspace-root component, leaving no directory behind', () => { const clonePath = path.join(helper.scopes.e2eDir, 'not-a-root'); const cmd = () => diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index c098c7bc0d36..a58f79c41e77 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -2,9 +2,24 @@ import { expect } from 'chai'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { ensureEmptyDir, resolveComponentDir } from './clone'; +import { ComponentID } from '@teambit/component-id'; +import { ensureEmptyDir, resolveClonePath, resolveComponentDir } from './clone'; import { WorkspaceRootMain } from './workspace-root.main.runtime'; +describe('resolveClonePath', () => { + const rootId = ComponentID.fromString('my-org.my-scope/my-root'); + it('should default the directory to the component name, as git names a working tree', () => { + expect(resolveClonePath(undefined, rootId)).to.equal(path.resolve('my-root')); + }); + it('should take the directory given, relative to the cwd', () => { + expect(resolveClonePath('some-dir', rootId)).to.equal(path.resolve('some-dir')); + }); + it('should keep an absolute directory as given', () => { + const absolute = path.resolve(os.tmpdir(), 'elsewhere'); + expect(resolveClonePath(absolute, rootId)).to.equal(absolute); + }); +}); + describe('resolveComponentDir', () => { const workspacePath = path.resolve(os.tmpdir(), 'ws'); it('should resolve a root-dir inside the workspace', () => { diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 19e22a1beced..04f4cebb92bc 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -18,6 +18,7 @@ import { ScopeAspect } from '@teambit/scope'; import { Remote } from '@teambit/scope.remotes'; import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; +import { getWorkspaceInfo } from '@teambit/workspace.modules.workspace-locator'; import { isWorkspaceRootComponent } from './workspace-root-data'; export type LoadBit = (workspacePath?: string) => Promise; @@ -221,8 +222,8 @@ class WorkspaceCloner { * no versions on purpose (see normalizeBitmapContentForVersioning), so the components come at their * heads - on main, or on the lane when one is given - and a root version pins the root files only. * - * the directory must be empty or absent. there is no override: this is a new workspace, not an - * import into one, so nothing at the target is the user's. + * the directory must be empty or absent, and outside any workspace. there is no override: this is a + * new workspace, not an import into one, so nothing at the target is the user's. */ export async function cloneWorkspace( rootId: ComponentID, @@ -230,7 +231,8 @@ export async function cloneWorkspace( options: CloneOptions, loadBit: LoadBit ): Promise { - const workspacePath = path.resolve(dir || rootId.name); + const workspacePath = resolveClonePath(dir, rootId); + await throwForWorkspaceAbove(workspacePath); const createdDir = await ensureEmptyDir(workspacePath); const originalCwd = process.cwd(); try { @@ -292,6 +294,29 @@ export function resolveComponentDir(workspacePath: string, entry: VersionedBitma return target; } +/** + * where the clone lands: the directory given, or one named after the component, as `git clone` names + * the working tree after the repository. relative to the cwd, the clone runs outside a workspace. + */ +export function resolveClonePath(dir: string | undefined, rootId: ComponentID): string { + return path.resolve(dir || rootId.name); +} + +/** + * a clone makes its own workspace. the init below takes the nearest workspace at or above the target + * instead of making one when there is a workspace above it, and the clone then writes its components + * into that workspace's `.bitmap` - reporting success while leaving someone else's workspace holding + * entries for a tree it does not own. + */ +async function throwForWorkspaceAbove(workspacePath: string): Promise { + const workspaceInfo = await getWorkspaceInfo(workspacePath); + if (!workspaceInfo) return; + throw new BitError( + `unable to clone into "${workspacePath}", it is inside the workspace at "${workspaceInfo.path}". +a clone creates a workspace of its own, run it outside any workspace` + ); +} + /** * the workspace is written into this directory and a failed clone empties it again, so it has to be * the directory it appears to be: a symbolic link would put the workspace, and then the cleanup, diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts index cb7cd04befd5..6ea5acbb209c 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.spec.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -14,19 +14,29 @@ const rootId = ComponentID.fromString('my-scope/my-root@0.0.7'); describe('workspace-root data', () => { describe('findWorkspaceRootMap', () => { + const entry = (id: ComponentID, rootDir: string, overrides: Record = {}) => ({ + id, + rootDir, + isAvailableOnCurrentLane: true, + isRemoved: () => false, + ...overrides, + }); + const bitMapOf = (entries: Record[]) => ({ components: entries }) as unknown as BitMap; it('should return the entry tracked at the workspace root', () => { - const bitMap = { - components: [ - { id: ComponentID.fromString('my-scope/comp1'), rootDir: 'comp1' }, - { id: rootId, rootDir: '.' }, - ], - } as unknown as BitMap; + const bitMap = bitMapOf([entry(ComponentID.fromString('my-scope/comp1'), 'comp1'), entry(rootId, '.')]); expect(findWorkspaceRootMap(bitMap)?.id.toString()).to.equal(rootId.toString()); }); it('should return undefined when no component owns the workspace root', () => { - const bitMap = { - components: [{ id: ComponentID.fromString('my-scope/comp1'), rootDir: 'comp1' }], - } as unknown as BitMap; + const bitMap = bitMapOf([entry(ComponentID.fromString('my-scope/comp1'), 'comp1')]); + expect(findWorkspaceRootMap(bitMap)).to.be.undefined; + }); + it('should ignore a root of another lane, this lane is rootless', () => { + // it stays in .bitmap so a switch back can restore it. snapping here would otherwise tag it along + const bitMap = bitMapOf([entry(rootId, '.', { isAvailableOnCurrentLane: false })]); + expect(findWorkspaceRootMap(bitMap)).to.be.undefined; + }); + it('should ignore a removed root', () => { + const bitMap = bitMapOf([entry(rootId, '.', { isRemoved: () => true })]); expect(findWorkspaceRootMap(bitMap)).to.be.undefined; }); }); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts index f221942d76f7..6b3299d318b4 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -27,9 +27,15 @@ export type WorkspaceRootData = { /** * the entry of the component that owns the workspace root (rootDir "."), if the workspace has one. + * + * a root created on another lane, or removed, stays in `.bitmap` so a switch back can restore it. + * it is not this workspace's root though, and tagging or snapping would otherwise bring it along. */ export function findWorkspaceRootMap(bitMap: BitMap): ComponentMap | undefined { - return bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); + return bitMap.components.find( + (componentMap) => + componentMap.rootDir === WORKSPACE_ROOT_DIR && componentMap.isAvailableOnCurrentLane && !componentMap.isRemoved() + ); } function findData(extensions: ExtensionDataList): WorkspaceRootData | undefined { From a8013dcb002a867ee60c71e8674c2719f3341c2e Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 10:54:21 -0400 Subject: [PATCH 059/102] fix(bit-map): compare root-dirs as directories, not as strings "packages/foo/" and "packages/foo" are the same directory. `bit add` normalizes before the check, but an entry loaded from a hand-written .bitmap keeps its spelling, and two components could then claim one directory. Co-Authored-By: Claude Opus 5 (1M context) --- components/legacy/bit-map/bit-map.spec.ts | 23 +++++++++++++++++++++++ components/legacy/bit-map/bit-map.ts | 5 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 7587fff5d750..87a7d8d07d21 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -308,6 +308,29 @@ ${JSON.stringify( bitMap.addComponent(componentParams); expect(() => bitMap.addComponent(componentParams)).to.not.throw(); }); + it('should reject it however the existing entry spells the directory, a .bitmap can be edited by hand', async () => { + // `bit add` normalizes the root-dir before it gets here, a hand-written entry is loaded as it is + const bitMap = await getBitmapInstance(); + bitMap.loadComponents( + { + 'my-scope/comp1': { + name: 'comp1', + scope: 'my-scope', + version: '0.0.1', + mainFile: 'index.ts', + rootDir: 'packages/comp1/', + exported: true, + }, + }, + 'my-scope' + ); + const addAnother = () => + bitMap.addComponent({ + ...componentParams, + componentId: ComponentID.fromObject({ name: 'comp2' }, 'my-scope'), + }); + expect(addAnother).to.throw('already used by another component'); + }); }); describe('fileContentsForVersioning', () => { const rawBitmap = Buffer.from( diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 63f0ecca14ff..2b91fd7a75f7 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -116,11 +116,14 @@ export class BitMap { const relative = path.relative(parent, child); return relative && !relative.startsWith('..'); }; + // as directories, not as strings: "comp1/" and "comp1" are the same one, and a .bitmap edited by + // hand can spell it either way + const isSameDir = (dirA: string, dirB: string) => path.relative(dirA, dirB) === ''; this.components.forEach((existingComponentMap) => { if (!existingComponentMap.rootDir || existingComponentMap.id.isEqualWithoutVersion(id)) return; // a root-dir has one owner. .bitmap validates this on load (throwForDuplicateRootDirs), it is // rejected here so the operation writing the entry fails, not the next command. - if (existingComponentMap.rootDir === rootDir) { + if (isSameDir(existingComponentMap.rootDir, rootDir)) { throw new BitError( `unable to add "${id.toString()}", its rootDir "${rootDir}" is already used by another component "${existingComponentMap.id.toString()}"` ); From 536603706850b291942c010e714d67646051a331 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 11:09:24 -0400 Subject: [PATCH 060/102] fix(bit-map): drop a deleted component from the versioned .bitmap a soft-delete stays in the map until the export that finalizes it, and its marker lives in the config the normalization drops. a root snapped in that window listed it as an ordinary member, so a clone brought the component back. Co-Authored-By: Claude Opus 5 (1M context) --- components/legacy/bit-map/bit-map.spec.ts | 31 +++++++++++++++++++++++ components/legacy/bit-map/bit-map.ts | 9 +++++++ 2 files changed, 40 insertions(+) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 87a7d8d07d21..bbbb08827971 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -205,6 +205,37 @@ describe('BitMap', function () { it('should be idempotent, otherwise the root component would never converge', () => { expect(normalizeBitmapContentForVersioning(normalized)).to.equal(normalized); }); + describe('a component the workspace deleted, still pending in the map', () => { + const withDeleted = JSON.stringify({ + comp1: { name: 'comp1', scope: 'my-scope', rootDir: 'comp1' }, + comp2: { + name: 'comp2', + scope: 'my-scope', + rootDir: 'comp2', + config: { 'teambit.component/remove': { removed: true } }, + }, + comp3: { + name: 'comp3', + scope: 'my-scope', + rootDir: 'comp3', + config: { 'teambit.component/remove': { removed: false } }, + }, + }); + let deletedParsed: Record; + before(() => { + const result = normalizeBitmapContentForVersioning(withDeleted); + deletedParsed = JSON.parse(result.slice(result.indexOf('{'))); + }); + it('should drop it, or a clone of this root would bring the component back', () => { + // the marker lives in the config this function drops, so keeping the entry would leave it + // indistinguishable from an ordinary member + expect(deletedParsed).to.not.have.property('comp2'); + }); + it('should keep a component that was recovered', () => { + expect(deletedParsed).to.have.property('comp3'); + expect(deletedParsed).to.have.property('comp1'); + }); + }); describe('a component of another lane, left in the map so a switch back can restore it', () => { const withLaneComp = JSON.stringify({ comp1: { name: 'comp1', scope: 'my-scope', rootDir: 'comp1' }, diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 2b91fd7a75f7..4c5967d6f51f 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -13,6 +13,7 @@ import { AUTO_GENERATED_MSG, AUTO_GENERATED_STAMP, BIT_MAP, + Extensions, OLD_BIT_MAP, VERSION_DELIMITER, BITMAP_PREFIX_MESSAGE, @@ -1134,6 +1135,14 @@ export function normalizeBitmapContentForVersioning(rawContent: string): string delete parsed[key]; return; } + // a component the workspace deleted. the deletion stays in the map until the export that + // finalizes it, and its marker lives in `config`, which is dropped below - so a root versioned + // in that window would list it as an ordinary member and a clone would bring it back. + const removeConfig = entry.config?.[Extensions.remove]; + if (removeConfig && removeConfig !== '-' && removeConfig.removed) { + delete parsed[key]; + return; + } // which lane a component is on, and whether it ever reached main: workspace state that a lane // switch and a lane merge flip, the same as LANE_KEY above delete entry.isAvailableOnCurrentLane; From 47709cf37f4ea78179182910c6b5292fb6e30252 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 11:30:05 -0400 Subject: [PATCH 061/102] fix(workspace-root): render the components a clone could not fetch as a section with the error symbol missing state takes the error symbol per the CLI style guide, and a listed section reads better than one long sentence. also drops an e2e case that determine-main-file.spec.ts already covers. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/harmony/add-harmony.e2e.ts | 3 --- scopes/workspace/workspace-root/clone.cmd.ts | 23 ++++++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index da82acacb877..2c5b0ce48022 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -53,9 +53,6 @@ describe('add command on Harmony', function () { it('should save "." as the rootDir', () => { expect(helper.bitMap.read()['ws-root'].rootDir).to.equal('.'); }); - it('should default the main file to workspace.jsonc, the root has no entry point of its own', () => { - expect(helper.bitMap.read()['ws-root'].mainFile).to.equal('workspace.jsonc'); - }); it('should own the root files, including files added after it was tracked', () => { expect(rootFiles).to.include('README.md'); expect(rootFiles).to.include('LICENSE'); diff --git a/scopes/workspace/workspace-root/clone.cmd.ts b/scopes/workspace/workspace-root/clone.cmd.ts index d2a47456b144..0c9356eaf1d7 100644 --- a/scopes/workspace/workspace-root/clone.cmd.ts +++ b/scopes/workspace/workspace-root/clone.cmd.ts @@ -1,6 +1,14 @@ import path from 'path'; import type { Command, CommandOptions } from '@teambit/cli'; -import { formatHint, formatSuccessSummary, formatWarningSummary, joinSections } from '@teambit/cli'; +import { + errorSymbol, + formatHint, + formatItem, + formatSection, + formatSuccessSummary, + formatWarningSummary, + joinSections, +} from '@teambit/cli'; import type { CloneResult } from './clone'; import type { WorkspaceRootMain } from './workspace-root.main.runtime'; @@ -60,12 +68,13 @@ export function formatCloneResult(result: CloneResult, relativeDir: string): str `cloned ${result.rootId.toString()} into "${relativeDir}" with ${count} component${count === 1 ? '' : 's'}` ); const lane = result.laneId ? formatHint(`(the workspace is on lane ${result.laneId.toString()})`) : ''; - const missingCount = result.missing.length; - const missing = missingCount - ? formatWarningSummary( - `${missingCount} component${missingCount === 1 ? '' : 's'} the root lists ${missingCount === 1 ? 'is' : 'are'} not on ${missingCount === 1 ? 'its' : 'their'} remote (never exported, or exported elsewhere), so the clone is without: ${result.missing.join(', ')}` - ) - : ''; + // missing state takes the error symbol, see cli-output-style-guide.md. the clone itself succeeded, + // which the summary above says, so the section is about the components rather than the command. + const missing = formatSection( + 'components the root lists that are not on their remote', + 'never exported, or exported elsewhere - the clone is without them', + result.missing.map((missingId) => formatItem(missingId, errorSymbol)) + ); const installation = result.installationError ? formatWarningSummary( `the dependencies were not installed: ${result.installationError.message}\nrun "bit install" in the workspace to retry` From ef5271d8eec809417093aa5374a44fd2d1e1a1c8 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 11:49:58 -0400 Subject: [PATCH 062/102] test(tracker): cover add-time ignore filtering in a spec instead of e2e --- e2e/harmony/bit-ignore.e2e.ts | 5 -- .../component/tracker/add-components.spec.ts | 48 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 scopes/component/tracker/add-components.spec.ts diff --git a/e2e/harmony/bit-ignore.e2e.ts b/e2e/harmony/bit-ignore.e2e.ts index 600d740bb3ed..2639a01db535 100644 --- a/e2e/harmony/bit-ignore.e2e.ts +++ b/e2e/harmony/bit-ignore.e2e.ts @@ -24,11 +24,6 @@ describe('Bit Ignore functionality', function () { expect(files).to.not.include('hello.json'); expect(files).to.include('index.js'); }); - it('should respect it at add time as well, not only on the next rescan', () => { - const output = helper.command.addComponent('comp1', { i: 'comp1' }); - expect(output).to.have.string('index.js'); - expect(output).to.not.have.string('hello.json'); - }); }); describe('adding .bitignore in the root dir', () => { before(() => { diff --git a/scopes/component/tracker/add-components.spec.ts b/scopes/component/tracker/add-components.spec.ts new file mode 100644 index 000000000000..3a7300f10b59 --- /dev/null +++ b/scopes/component/tracker/add-components.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import fs from 'fs-extra'; +import path from 'path'; +import { loadManyAspects } from '@teambit/harmony.testing.load-aspect'; +import type { WorkspaceData } from '@teambit/workspace.testing.mock-workspace'; +import { mockWorkspace, destroyWorkspace } from '@teambit/workspace.testing.mock-workspace'; +import { WorkspaceAspect } from '@teambit/workspace'; +import { TrackerAspect } from './tracker.aspect'; +import type { TrackerMain } from './tracker.main.runtime'; + +/** + * which files `bit add` ends up tracking. one harmony load stands in for a process per command, so + * the rules that pick the files are covered here rather than as e2e. + */ +describe('the files bit add tracks', function () { + this.timeout(0); + + let workspaceData: WorkspaceData; + let addedFiles: string[]; + + before(async () => { + workspaceData = mockWorkspace(); + const { workspacePath } = workspaceData; + const write = (relPath: string, content: string) => fs.outputFileSync(path.join(workspacePath, relPath), content); + write('comp1/index.js', 'module.exports = () => "comp1";\n'); + write('comp1/.bitignore', '*.json\n'); + write('comp1/hello.json', '{ "hello": "world" }\n'); + const harmony = await loadManyAspects([WorkspaceAspect, TrackerAspect], workspacePath); + const tracker = harmony.get(TrackerAspect.id); + const results = await tracker.addForCLI({ + componentPaths: [path.join(workspacePath, 'comp1')], + id: 'comp1', + override: false, + }); + addedFiles = results.addedComponents[0].files.map((file) => file.relativePath); + }); + after(async () => { + await destroyWorkspace(workspaceData); + }); + + it('should apply the ignore file of the component being added, as the rescan does', () => { + expect(addedFiles).to.include('index.js'); + expect(addedFiles).to.not.include('hello.json'); + }); + it('should keep the ignore file itself, it is a source of the component', () => { + expect(addedFiles).to.include('.bitignore'); + }); +}); From 38fcc79904d56280e2148aa84ec40a7ad87e3cc6 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 12:30:31 -0400 Subject: [PATCH 063/102] fix(tracker): report a main file the component's ignore file excludes, instead of failing as removed --- .../component/tracker/add-components.spec.ts | 84 ++++++++++++++----- scopes/component/tracker/add-components.ts | 9 ++ 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/scopes/component/tracker/add-components.spec.ts b/scopes/component/tracker/add-components.spec.ts index 3a7300f10b59..0ae3d8bb60dd 100644 --- a/scopes/component/tracker/add-components.spec.ts +++ b/scopes/component/tracker/add-components.spec.ts @@ -15,34 +15,72 @@ import type { TrackerMain } from './tracker.main.runtime'; describe('the files bit add tracks', function () { this.timeout(0); - let workspaceData: WorkspaceData; - let addedFiles: string[]; + const workspaces: WorkspaceData[] = []; - before(async () => { - workspaceData = mockWorkspace(); + /** a component whose own ignore file hides the .json next to its source */ + const compWithIgnoredJson = { + 'comp1/index.js': 'module.exports = () => "comp1";\n', + 'comp1/.bitignore': '*.json\n', + 'comp1/hello.json': '{ "hello": "world" }\n', + }; + + async function setup(files: Record) { + const workspaceData = mockWorkspace(); + workspaces.push(workspaceData); const { workspacePath } = workspaceData; - const write = (relPath: string, content: string) => fs.outputFileSync(path.join(workspacePath, relPath), content); - write('comp1/index.js', 'module.exports = () => "comp1";\n'); - write('comp1/.bitignore', '*.json\n'); - write('comp1/hello.json', '{ "hello": "world" }\n'); + Object.entries(files).forEach(([filePath, content]) => + fs.outputFileSync(path.join(workspacePath, filePath), content) + ); const harmony = await loadManyAspects([WorkspaceAspect, TrackerAspect], workspacePath); - const tracker = harmony.get(TrackerAspect.id); - const results = await tracker.addForCLI({ - componentPaths: [path.join(workspacePath, 'comp1')], - id: 'comp1', - override: false, - }); - addedFiles = results.addedComponents[0].files.map((file) => file.relativePath); - }); + // addForCLI resolves componentPaths and main against the cwd, so the tests pass absolute paths + return { workspacePath, tracker: harmony.get(TrackerAspect.id) }; + } + + /** chai has no async throw assertion that also matches a message */ + async function expectToReject(fn: () => Promise, messagePart: string) { + try { + await fn(); + } catch (err: any) { + expect(err.message).to.have.string(messagePart); + return; + } + throw new Error(`expected to reject with "${messagePart}", but it resolved`); + } + after(async () => { - await destroyWorkspace(workspaceData); + await Promise.all(workspaces.map((workspaceData) => destroyWorkspace(workspaceData))); }); - it('should apply the ignore file of the component being added, as the rescan does', () => { - expect(addedFiles).to.include('index.js'); - expect(addedFiles).to.not.include('hello.json'); - }); - it('should keep the ignore file itself, it is a source of the component', () => { - expect(addedFiles).to.include('.bitignore'); + describe('the ignore file of the component being added', () => { + let addedFiles: string[]; + before(async () => { + const { workspacePath, tracker } = await setup(compWithIgnoredJson); + const results = await tracker.addForCLI({ + componentPaths: [path.join(workspacePath, 'comp1')], + id: 'comp1', + override: false, + }); + addedFiles = results.addedComponents[0].files.map((file) => file.relativePath); + }); + it('should apply it at add time, as the rescan does', () => { + expect(addedFiles).to.include('index.js'); + expect(addedFiles).to.not.include('hello.json'); + }); + it('should keep the ignore file itself, it is a source of the component', () => { + expect(addedFiles).to.include('.bitignore'); + }); + it('should refuse a main file it excludes, rather than track what the next rescan drops', async () => { + const { workspacePath, tracker } = await setup(compWithIgnoredJson); + await expectToReject( + () => + tracker.addForCLI({ + componentPaths: [path.join(workspacePath, 'comp1')], + id: 'comp1', + main: path.join(workspacePath, 'comp1/hello.json'), + override: false, + }), + 'was excluded from file list' + ); + }); }); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index bf64d3948097..ebca96ef9ecd 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -607,6 +607,15 @@ you can add the directory these files are located at and it'll change the root d return { relativePath: pathNormalizeToLinux(match), test: false, name: path.basename(match) }; }); const resolvedMainFile = this._addMainFileToFiles(filteredMatchedFiles); + // that puts the main file back into the list after the filtering above, and checks it against the + // workspace ignore rules only - so the component's own ignore file is applied to it here. without + // it the add fails further down on a main file the rescan already dropped, saying it was removed. + if (resolvedMainFile) { + const mainNormalized = pathNormalizeToLinux(resolvedMainFile); + const excludedByOwnIgnoreFile = + matchesNotIgnored.includes(mainNormalized) && !keptByOwnIgnoreFile.has(relativeToComponent(mainNormalized)); + if (excludedByOwnIgnoreFile) throw new ExcludedMainFile(relativeToComponent(mainNormalized)); + } const absoluteComponentPath = pathNormalizeToLinux(path.resolve(componentPath)); const splitPath = absoluteComponentPath.split('/'); From 3f5bb4907bdabc3404c57083762ea35e745eccfc Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 12:30:39 -0400 Subject: [PATCH 064/102] fix(component-writer): keep a root version from overwriting the source of a component nested in it --- .../component-writer/component-writer.spec.ts | 71 +++++++++++++++++++ .../component-writer/component-writer.ts | 18 ++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 scopes/component/component-writer/component-writer.spec.ts diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts new file mode 100644 index 000000000000..883368c0fea0 --- /dev/null +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai'; +import ComponentWriter, { isOwnedByNestedComponent } from './component-writer'; + +describe('isOwnedByNestedComponent', () => { + const nested = ['packages/comp1', 'packages/comp2']; + it('should claim a file inside a nested component', () => { + expect(isOwnedByNestedComponent('packages/comp1/index.js', nested)).to.be.true; + }); + it('should claim a file deeper inside a nested component', () => { + expect(isOwnedByNestedComponent('packages/comp1/src/util.js', nested)).to.be.true; + }); + it('should leave a file the root owns', () => { + expect(isOwnedByNestedComponent('packages/readme.md', nested)).to.be.false; + expect(isOwnedByNestedComponent('workspace.jsonc', nested)).to.be.false; + }); + it('should not claim a directory that merely shares a prefix with a nested one', () => { + expect(isOwnedByNestedComponent('packages/comp10/index.js', nested)).to.be.false; + }); + it('should not claim the nested root-dir itself, only what is under it', () => { + expect(isOwnedByNestedComponent('packages/comp1', nested)).to.be.false; + }); + it('should claim nothing when no component is nested, the ordinary case', () => { + expect(isOwnedByNestedComponent('packages/comp1/index.js', [])).to.be.false; + }); +}); + +describe('populateFilesToWriteToComponentDir', () => { + /** + * the method only reads these fields off the writer. building it through the constructor would + * need a consumer and a scope, which say nothing about the file-set it produces. + */ + function writerFor(files: string[], nestedRootDirs: string[]) { + const written: string[] = []; + const writer = Object.create(ComponentWriter.prototype); + Object.assign(writer, { + writeToPath: '.', + override: true, + writeConfig: false, + deleteBitDirContent: false, + bitMap: { getNestedRootDirs: () => nestedRootDirs }, + component: { + files: files.map((relative) => ({ relative })), + dataToPersist: { addFile: (file: any) => written.push(file.relative) }, + license: undefined, + }, + }); + return { writer, written }; + } + + it('should not write over the source of a component nested in the root, an old version still carries it', async () => { + // the root was snapped before comp1 was extracted out of it, so that version holds comp1's files + const { writer, written } = writerFor( + ['workspace.jsonc', 'packages/comp1/index.js', 'readme.md'], + ['packages/comp1'] + ); + await writer.populateFilesToWriteToComponentDir(); + expect(written).to.deep.equal(['workspace.jsonc', 'readme.md']); + }); + + it('should write every file when nothing is nested', async () => { + const { writer, written } = writerFor(['workspace.jsonc', 'packages/comp1/index.js'], []); + await writer.populateFilesToWriteToComponentDir(); + expect(written).to.deep.equal(['workspace.jsonc', 'packages/comp1/index.js']); + }); + + it('should keep skipping the live .bitmap, which is never written from a versioned copy', async () => { + const { writer, written } = writerFor(['.bitmap', 'workspace.jsonc'], []); + await writer.populateFilesToWriteToComponentDir(); + expect(written).to.deep.equal(['workspace.jsonc']); + }); +}); diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index 1c989fd4531c..ed1d9ba294f8 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -28,6 +28,19 @@ export type ComponentWriterProps = { skipUpdatingBitMap?: boolean; }; +/** + * a version of the workspace-root component can carry files that a component nested inside it owns + * today - it was snapped before that component was extracted out of the root. they belong to the + * nested component now (see getNestedRootDirs), so writing them back would replace its source with + * an older copy of it. + */ +export function isOwnedByNestedComponent( + relativePath: PathLinuxRelative, + nestedRootDirs: PathLinuxRelative[] +): boolean { + return nestedRootDirs.some((nestedRootDir) => relativePath.startsWith(`${nestedRootDir}/`)); +} + export default class ComponentWriter { component: Component; writeToPath: PathLinuxRelative; @@ -105,9 +118,12 @@ export default class ComponentWriter { if (this.deleteBitDirContent) { this.component.dataToPersist.removePath(new RemovePath(this.writeToPath)); } + const nestedRootDirs = this.bitMap.getNestedRootDirs(this.writeToPath); this.component.files.forEach((file) => { + const relativePath = pathNormalizeToLinux(file.relative); // the live map is never written from a versioned copy, see isWorkspaceMapFile - if (isWorkspaceMapFile(pathNormalizeToLinux(file.relative))) return; + if (isWorkspaceMapFile(relativePath)) return; + if (isOwnedByNestedComponent(relativePath, nestedRootDirs)) return; file.override = this.override; this.component.dataToPersist.addFile(file); }); From 3ddfdb6d1e99287e390733b3042759b62671f41d Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 12:49:38 -0400 Subject: [PATCH 065/102] fix(bit-map): let the root scan the dir of a removed or other-lane component --- components/legacy/bit-map/bit-map.spec.ts | 17 +++++++++++++++++ components/legacy/bit-map/bit-map.ts | 11 ++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index bbbb08827971..b53d66c4e41e 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -5,6 +5,7 @@ import os from 'os'; import * as path from 'path'; import { ComponentID } from '@teambit/component-id'; import { BitId } from '@teambit/legacy-bit-id'; +import { Extensions } from '@teambit/legacy.constants'; import { logger } from '@teambit/legacy.logger'; import { BitMap, @@ -142,6 +143,22 @@ describe('BitMap', function () { expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal(['packages/comp1']); expect(bitMap.getNestedRootDirs('packages/comp1')).to.deep.equal([]); }); + it('getNestedRootDirs should not subtract the dir of a component of another lane', async () => { + // it is left in the map so a switch back can restore it. it owns nothing here in the meantime, + // so the root scans its dir - otherwise the files it left would belong to no component at all + const bitMap = await getBitmapInstance(); + bitMap.addComponent(rootComponentParams); + const nested = bitMap.addComponent(nestedComponentParams); + nested.isAvailableOnCurrentLane = false; + expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal([]); + }); + it('getNestedRootDirs should not subtract the dir of a removed component', async () => { + const bitMap = await getBitmapInstance(); + bitMap.addComponent(rootComponentParams); + const nested = bitMap.addComponent(nestedComponentParams); + nested.config = { [Extensions.remove]: { removed: true } }; + expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal([]); + }); }); describe('normalizeBitmapContentForVersioning', () => { const rawBitmap = JSON.stringify( diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 4c5967d6f51f..5667bd13ebe0 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -250,9 +250,14 @@ export class BitMap { // only the workspace root may contain other components (see throwForExistingParentDir), so any // other root-dir has nothing nested in it. this runs once per component on every load. if (rootDir !== WORKSPACE_ROOT_DIR) return []; - return this.components - .map((componentMap) => componentMap.rootDir) - .filter((nested): nested is PathLinuxRelative => Boolean(nested) && nested !== WORKSPACE_ROOT_DIR); + return ( + this.components + // a component removed, or left here by a lane it is unavailable on, does not own its dir in the + // meantime - the root scans it, or the files it kept would belong to no component at all. + .filter((componentMap) => componentMap.isAvailableOnCurrentLane && !componentMap.isRemoved()) + .map((componentMap) => componentMap.rootDir) + .filter((nested): nested is PathLinuxRelative => Boolean(nested) && nested !== WORKSPACE_ROOT_DIR) + ); } /** From e38fc003768f36bbc57d64ba142edcbf63784a65 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 12:49:42 -0400 Subject: [PATCH 066/102] fix(component-writer): write an ordinary component's own .bitmap, only the root's is the workspace map --- .../component-writer/component-writer.spec.ts | 10 ++++++++-- scopes/component/component-writer/component-writer.ts | 9 ++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 883368c0fea0..42f8899fc094 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -29,11 +29,11 @@ describe('populateFilesToWriteToComponentDir', () => { * the method only reads these fields off the writer. building it through the constructor would * need a consumer and a scope, which say nothing about the file-set it produces. */ - function writerFor(files: string[], nestedRootDirs: string[]) { + function writerFor(files: string[], nestedRootDirs: string[], writeToPath = '.') { const written: string[] = []; const writer = Object.create(ComponentWriter.prototype); Object.assign(writer, { - writeToPath: '.', + writeToPath, override: true, writeConfig: false, deleteBitDirContent: false, @@ -68,4 +68,10 @@ describe('populateFilesToWriteToComponentDir', () => { await writer.populateFilesToWriteToComponentDir(); expect(written).to.deep.equal(['workspace.jsonc']); }); + + it("should write an ordinary component's own .bitmap, only the root tracks the workspace one", async () => { + const { writer, written } = writerFor(['.bitmap', 'index.js'], [], 'comp1'); + await writer.populateFilesToWriteToComponentDir(); + expect(written).to.deep.equal(['.bitmap', 'index.js']); + }); }); diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index ed1d9ba294f8..32d47931320a 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -3,7 +3,7 @@ import type { Scope } from '@teambit/legacy.scope'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { BitMap, ComponentMap } from '@teambit/legacy.bit-map'; -import { isWorkspaceMapFile } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -119,10 +119,13 @@ export default class ComponentWriter { this.component.dataToPersist.removePath(new RemovePath(this.writeToPath)); } const nestedRootDirs = this.bitMap.getNestedRootDirs(this.writeToPath); + const isWorkspaceRoot = this.writeToPath === WORKSPACE_ROOT_DIR; this.component.files.forEach((file) => { const relativePath = pathNormalizeToLinux(file.relative); - // the live map is never written from a versioned copy, see isWorkspaceMapFile - if (isWorkspaceMapFile(relativePath)) return; + // the live map is never written from a versioned copy, see isWorkspaceMapFile. only the root + // tracks it - an ordinary component's own .bitmap is a file like any other, the rule the + // checkout applies when it removes files. + if (isWorkspaceRoot && isWorkspaceMapFile(relativePath)) return; if (isOwnedByNestedComponent(relativePath, nestedRootDirs)) return; file.override = this.override; this.component.dataToPersist.addFile(file); From ff8fe8ea5b791a38d12b93cd9c42bc11855c609a Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 12:49:44 -0400 Subject: [PATCH 067/102] fix(tracker): keep the files bit generated when trackAllFiles is on, as the rescan does --- .../component/tracker/add-components.spec.ts | 37 ++++++++++++++++++- scopes/component/tracker/add-components.ts | 6 ++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/scopes/component/tracker/add-components.spec.ts b/scopes/component/tracker/add-components.spec.ts index 0ae3d8bb60dd..4b151e15d53d 100644 --- a/scopes/component/tracker/add-components.spec.ts +++ b/scopes/component/tracker/add-components.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import fs from 'fs-extra'; import path from 'path'; +import { AUTO_GENERATED_MSG } from '@teambit/legacy.constants'; import { loadManyAspects } from '@teambit/harmony.testing.load-aspect'; import type { WorkspaceData } from '@teambit/workspace.testing.mock-workspace'; import { mockWorkspace, destroyWorkspace } from '@teambit/workspace.testing.mock-workspace'; @@ -24,10 +25,20 @@ describe('the files bit add tracks', function () { 'comp1/hello.json': '{ "hello": "world" }\n', }; - async function setup(files: Record) { + /** the workspace aspect holds the flag, and workspace.jsonc carries comments that JSON.parse rejects */ + function enableTrackAllFiles(workspacePath: string) { + const configPath = path.join(workspacePath, 'workspace.jsonc'); + const content = fs.readFileSync(configPath, 'utf8'); + const workspaceKey = '"teambit.workspace/workspace": {'; + if (!content.includes(workspaceKey)) throw new Error(`"${workspaceKey}" is no longer in the mock workspace.jsonc`); + fs.writeFileSync(configPath, content.replace(workspaceKey, `${workspaceKey}\n "trackAllFiles": true,`)); + } + + async function setup(files: Record, opts: { trackAllFiles?: boolean } = {}) { const workspaceData = mockWorkspace(); workspaces.push(workspaceData); const { workspacePath } = workspaceData; + if (opts.trackAllFiles) enableTrackAllFiles(workspacePath); Object.entries(files).forEach(([filePath, content]) => fs.outputFileSync(path.join(workspacePath, filePath), content) ); @@ -83,4 +94,28 @@ describe('the files bit add tracks', function () { ); }); }); + + describe('a file bit generated, which carries its banner', () => { + const compWithGeneratedFile = { + 'comp1/index.js': 'module.exports = () => "comp1";\n', + 'comp1/package.json': `${AUTO_GENERATED_MSG}{ "name": "comp1" }\n`, + }; + const filesOf = async (trackAllFiles: boolean) => { + const { workspacePath, tracker } = await setup(compWithGeneratedFile, { trackAllFiles }); + const results = await tracker.addForCLI({ + componentPaths: [path.join(workspacePath, 'comp1')], + id: 'comp1', + override: false, + }); + return results.addedComponents[0].files.map((file) => file.relativePath); + }; + it('should drop it by default, it is not source', async () => { + expect(await filesOf(false)).to.not.include('package.json'); + }); + it('should keep it with trackAllFiles on, which is what the rescan does', async () => { + // the rescan never looks at the banner, so dropping it here would leave the two disagreeing: + // the file is missing from the add, then turns up as a new file on the next status + expect(await filesOf(true)).to.include('package.json'); + }); + }); }); diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index ebca96ef9ecd..855093d3ad0f 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -265,7 +265,11 @@ export default class AddComponents { // the workspace map carries the auto-generated banner, but the root component tracks it on // purpose (see isWorkspaceMapFile), and the rescan keeps it. the add-time file-set does too. const isAutoGenerated = await isAutoGeneratedFile(filePath); - if (isAutoGenerated && !(isWorkspaceRoot && isWorkspaceMapFile(file.relativePath))) { + // trackAllFiles keeps the files bit generates, and the rescan never looks at the banner at all + // (see getFilesByDir) - so dropping them here would leave the add-time file-set short of it. + const keepAutoGenerated = + this.consumer.config.trackAllFiles || (isWorkspaceRoot && isWorkspaceMapFile(file.relativePath)); + if (isAutoGenerated && !keepAutoGenerated) { return null; } const caseSensitive = false; From 6c3a6e60c160157ca92ea0af63fd5e96b34e659d Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 12:49:46 -0400 Subject: [PATCH 068/102] test(e2e): drop an add case the getFilesByDir unit tests already cover more strictly --- e2e/harmony/add-harmony.e2e.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 2c5b0ce48022..58a3e48f2ce3 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -478,9 +478,6 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# workspace root\n'); helper.command.addComponent('.', { i: 'ws-root' }); }); - it('should track the package.json and tsconfig.json of a component', () => { - expect(helper.command.getComponentFiles('comp1')).to.include.members(['package.json', 'tsconfig.json']); - }); it('should track the package.json of the workspace root', () => { expect(helper.command.getComponentFiles('ws-root')).to.include('package.json'); }); From e18b450ff7879ef71f7731823889e39387da7ac2 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:07:28 -0400 Subject: [PATCH 069/102] fix(component-writer): keep skipping .bitmap for every component, no component but the root tracks one --- .../component-writer/component-writer.spec.ts | 52 ++++++++++++++++++- .../component-writer/component-writer.ts | 9 ++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 42f8899fc094..eb6c062579ed 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -1,5 +1,9 @@ import { expect } from 'chai'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import ComponentWriter, { isOwnedByNestedComponent } from './component-writer'; +import { ComponentWriterMain } from './component-writer.main.runtime'; describe('isOwnedByNestedComponent', () => { const nested = ['packages/comp1', 'packages/comp2']; @@ -69,9 +73,53 @@ describe('populateFilesToWriteToComponentDir', () => { expect(written).to.deep.equal(['workspace.jsonc']); }); - it("should write an ordinary component's own .bitmap, only the root tracks the workspace one", async () => { + it('should skip it for an ordinary component too, no component but the root ever tracks one', async () => { + // the scan drops a `.bitmap` at any other component's root (see getScanIgnorePatterns), so what a + // component versions is what a write lands const { writer, written } = writerFor(['.bitmap', 'index.js'], [], 'comp1'); await writer.populateFilesToWriteToComponentDir(); - expect(written).to.deep.equal(['.bitmap', 'index.js']); + expect(written).to.deep.equal(['index.js']); + }); +}); + +describe('the workspace-root import preflight checks', () => { + /** + * the checks read the incoming files, the nested root-dirs and the paths on disk. going through + * writeMany would need a workspace and a scope, which say nothing about which paths they look at. + */ + function runtimeFor(nestedRootDirs: string[], workspacePath: string) { + const main = Object.create(ComponentWriterMain.prototype); + // `consumer` is a getter on the class, so it is defined rather than assigned + Object.defineProperty(main, 'consumer', { + value: { + bitMap: { getNestedRootDirs: () => nestedRootDirs, components: [] }, + toAbsolutePath: (relativePath: string) => path.join(workspacePath, relativePath), + }, + }); + return main; + } + const componentFor = (files: string[]) => + ({ id: { toString: () => 'my-scope/ws-root' }, files: files.map((relative) => ({ relative })) }) as any; + + let workspacePath: string; + beforeEach(async () => { + workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), 'bit-preflight-')); + }); + afterEach(async () => { + await fs.remove(workspacePath); + }); + + it('should ignore a symlink inside a nested component, the write never goes through it', async () => { + // an older root version still carries comp1's files, but the writer leaves that directory alone + await fs.ensureDir(path.join(workspacePath, 'packages')); + await fs.symlink(os.tmpdir(), path.join(workspacePath, 'packages/comp1')); + const main = runtimeFor(['packages/comp1'], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['packages/comp1/index.js']))).to.not.throw(); + }); + + it('should still refuse a symlink on the way to a file the root does own', async () => { + await fs.symlink(os.tmpdir(), path.join(workspacePath, 'docs')); + const main = runtimeFor(['packages/comp1'], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['docs/readme.md']))).to.throw('is a symbolic link'); }); }); diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index 32d47931320a..ed1d9ba294f8 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -3,7 +3,7 @@ import type { Scope } from '@teambit/legacy.scope'; import type { PathLinuxRelative } from '@teambit/legacy.utils'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { BitMap, ComponentMap } from '@teambit/legacy.bit-map'; -import { isWorkspaceMapFile, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { isWorkspaceMapFile } from '@teambit/legacy.bit-map'; import type { ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -119,13 +119,10 @@ export default class ComponentWriter { this.component.dataToPersist.removePath(new RemovePath(this.writeToPath)); } const nestedRootDirs = this.bitMap.getNestedRootDirs(this.writeToPath); - const isWorkspaceRoot = this.writeToPath === WORKSPACE_ROOT_DIR; this.component.files.forEach((file) => { const relativePath = pathNormalizeToLinux(file.relative); - // the live map is never written from a versioned copy, see isWorkspaceMapFile. only the root - // tracks it - an ordinary component's own .bitmap is a file like any other, the rule the - // checkout applies when it removes files. - if (isWorkspaceRoot && isWorkspaceMapFile(relativePath)) return; + // the live map is never written from a versioned copy, see isWorkspaceMapFile + if (isWorkspaceMapFile(relativePath)) return; if (isOwnedByNestedComponent(relativePath, nestedRootDirs)) return; file.override = this.override; this.component.dataToPersist.addFile(file); From 7914f2fa2bbccae48ae14d5d7d8a37b8242ff69e Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:07:32 -0400 Subject: [PATCH 070/102] fix(component-writer): apply the nested-component filter to the root import preflight checks --- .../component-writer.main.runtime.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 4e573c8ee620..df26f2ceb0db 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -28,7 +28,7 @@ import { ConfigMergerAspect } from '@teambit/config-merger'; import type { MergeStrategy } from '@teambit/component.modules.merge-helper'; import type { Consumer } from '@teambit/legacy.consumer'; import type { ComponentWriterProps } from './component-writer'; -import ComponentWriter from './component-writer'; +import ComponentWriter, { isOwnedByNestedComponent } from './component-writer'; import { ComponentWriterAspect } from './component-writer.aspect'; export interface ManyComponentsWriterParams { @@ -375,9 +375,22 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace * file has to be real. this is about where the write goes, not what it replaces, so --override does * not waive it (it waives the conflict check, which is the other place the destination is looked at). */ + /** + * the incoming files a write would actually land at the workspace root. a component nested in the + * root owns its own directory, and populateFilesToWriteToComponentDir leaves those files alone - so + * the checks below must not reject an import over a path it would never touch. an older version of + * the root, snapped before that component was extracted out of it, still carries them. + */ + private filesThatWouldLand(component: ConsumerComponent) { + const nestedRootDirs = this.consumer.bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR); + return component.files.filter( + (file) => !isOwnedByNestedComponent(pathNormalizeToLinux(file.relative), nestedRootDirs) + ); + } + private throwForSymlinksInTheWay(component: ConsumerComponent) { const pathsInTheWay = new Set(); - component.files.forEach((file) => { + this.filesThatWouldLand(component).forEach((file) => { const segments = pathNormalizeToLinux(file.relative).split('/'); segments.forEach((_, index) => pathsInTheWay.add(segments.slice(0, index + 1).join('/'))); }); @@ -421,7 +434,7 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace componentMap.id.isEqualWithoutVersion(component.id) ); const generatedByInit = isFreshWorkspace ? [WORKSPACE_JSONC] : []; - const filesToOverwrite = component.files + const filesToOverwrite = this.filesThatWouldLand(component) .filter((file) => { const relativePath = pathNormalizeToLinux(file.relative); if (isWorkspaceMapFile(relativePath) || generatedByInit.includes(relativePath)) return false; From 1a68acdbd0c3c8ced7d97e3f36a713c4ee2c7c3e Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:10:23 -0400 Subject: [PATCH 071/102] test(tracker): cover the root-and-nested batch add in a spec instead of e2e --- e2e/harmony/add-harmony.e2e.ts | 24 ---------- .../tracker/track-workspace-root.spec.ts | 44 +++++++++++++++++++ 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 58a3e48f2ce3..3f18878250a6 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -74,30 +74,6 @@ describe('add command on Harmony', function () { expect(path.join(nodeModules, helper.general.getPackageNameByCompName('ws-root', false))).to.not.be.a.path(); }); }); - describe('adding the workspace root and a nested component in one command', () => { - let addedComponents: Array<{ id: string; files: string[] }>; - before(() => { - helper.scopeHelper.reInitWorkspace(); - helper.fs.outputFile('index.js', 'module.exports = {};\n'); - // a direct child ("bit add . comp1") is dropped from the batch as a wildcard expansion of ".", - // a pre-existing rule. a deeper one is added alongside the root. - helper.fs.outputFile('packages/comp1/index.js', 'module.exports = () => "comp1";\n'); - helper.fs.outputFile('packages/comp1/.npmrc', 'registry=https://example.com\n'); - addedComponents = JSON.parse(helper.command.runCmd('bit add . packages/comp1 --json')).addedComponents; - }); - it('should list the dotfiles of a nested component at add time, as the rescan tracks them', () => { - const nested = addedComponents.find((added) => added.id.endsWith('comp1')); - expect(nested?.files.some((file) => file.endsWith('.npmrc'))).to.be.true; - }); - it('should leave the nested component files out of the root, already at add time', () => { - // the nested component is not in .bitmap yet when the root is scanned, so the batch itself has - // to provide the exclusion. otherwise the two own the same files until the next rescan. - expect(addedComponents).to.have.lengthOf(2); - const root = addedComponents.find((added) => !added.id.endsWith('comp1')); - expect(root?.files).to.include('index.js'); - expect(root?.files.some((file) => file.startsWith('packages/'))).to.be.false; - }); - }); describe('workspace-root component and .bitmap', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/tracker/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts index 13d7022771a9..e94baa4b03b8 100644 --- a/scopes/component/tracker/track-workspace-root.spec.ts +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -6,6 +6,7 @@ import type { WorkspaceData } from '@teambit/workspace.testing.mock-workspace'; import { mockWorkspace, destroyWorkspace } from '@teambit/workspace.testing.mock-workspace'; import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import { TrackerAspect } from './tracker.aspect'; import type { TrackerMain } from './tracker.main.runtime'; @@ -104,6 +105,49 @@ describe('tracking the workspace root', function () { }); }); + describe('adding the workspace root and a nested component in one command', () => { + let addedComponents: { id: string; files: string[] }[]; + let tracked: Tracked; + before(async () => { + tracked = await setupWorkspace({ + 'index.js': 'module.exports = {};\n', + 'packages/comp1/index.js': 'module.exports = () => "comp1";\n', + 'packages/comp1/.npmrc': 'registry=https://example.com\n', + }); + // relative paths, as the command gets them: "." is resolved by the same glob the CLI feeds, and + // a direct child would be dropped from the batch as a wildcard expansion of it + const originalCwd = process.cwd(); + process.chdir(tracked.workspacePath); + try { + const results = await tracked.tracker.addForCLI({ + componentPaths: [WORKSPACE_ROOT_DIR, 'packages/comp1'], + override: false, + }); + addedComponents = results.addedComponents.map((added) => ({ + id: added.id.toString(), + files: added.files.map((file) => file.relativePath), + })); + } finally { + process.chdir(originalCwd); + } + }); + after(async () => { + await destroyWorkspace(tracked.workspaceData); + }); + it('should list the dotfiles of a nested component at add time, as the rescan tracks them', () => { + const nested = addedComponents.find((added) => added.id.endsWith('comp1')); + expect(nested?.files.some((file) => file.endsWith('.npmrc'))).to.be.true; + }); + it('should leave the nested component files out of the root, already at add time', () => { + // the nested component is not in .bitmap yet when the root is scanned, so the batch itself has + // to provide the exclusion. otherwise the two own the same files until the next rescan. + expect(addedComponents).to.have.lengthOf(2); + const root = addedComponents.find((added) => !added.id.endsWith('comp1')); + expect(root?.files).to.include('index.js'); + expect(root?.files.some((file) => file.startsWith('packages/'))).to.be.false; + }); + }); + describe('adding a nested component that holds the main file of the workspace root', () => { let tracked: Tracked; before(async () => { From 4e0642c1b8406f6bc94b2bd5f4c278480268307b Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:26:38 -0400 Subject: [PATCH 072/102] fix(bit-map): drop a trailing slash from a nested root-dir, callers append to it --- components/legacy/bit-map/bit-map.spec.ts | 13 +++++++++++++ components/legacy/bit-map/bit-map.ts | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index b53d66c4e41e..24dfa2cff8bc 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -152,6 +152,19 @@ describe('BitMap', function () { nested.isAvailableOnCurrentLane = false; expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal([]); }); + it('getNestedRootDirs should drop a trailing slash, which a hand-edited .bitmap may carry', async () => { + // callers append to these ("dir/**", "dir/"), and a doubled separator matches nothing - the root + // would then claim the nested component's files and a write would overwrite them + const bitMap = await getBitmapInstance(); + bitMap.loadComponents( + { + 'my-scope/ws-root': { scope: 'my-scope', version: '0.0.1', mainFile: 'README.md', rootDir: '.' }, + 'my-scope/comp1': { scope: 'my-scope', version: '0.0.1', mainFile: 'index.js', rootDir: 'packages/comp1/' }, + }, + 'my-scope' + ); + expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal(['packages/comp1']); + }); it('getNestedRootDirs should not subtract the dir of a removed component', async () => { const bitMap = await getBitmapInstance(); bitMap.addComponent(rootComponentParams); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 5667bd13ebe0..318e2528d5ef 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -257,6 +257,10 @@ export class BitMap { .filter((componentMap) => componentMap.isAvailableOnCurrentLane && !componentMap.isRemoved()) .map((componentMap) => componentMap.rootDir) .filter((nested): nested is PathLinuxRelative => Boolean(nested) && nested !== WORKSPACE_ROOT_DIR) + // without the trailing slash a .bitmap edited by hand may carry (see isSameDir). callers append + // to these - "dir/**" to exclude it from a scan, "dir/" to tell a file below it - and a doubled + // separator matches nothing, so the root would claim the nested component's files. + .map((nested) => nested.replace(/\/+$/, '')) ); } From ec7bf2a7dbaa1fe3b15e8afd9db785a0b17d531a Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:26:41 -0400 Subject: [PATCH 073/102] test(component-writer): pin that a tracked root component is written at the workspace root --- .../component-writer/component-writer.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index eb6c062579ed..aa974c9b49c9 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; +import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import ComponentWriter, { isOwnedByNestedComponent } from './component-writer'; import { ComponentWriterMain } from './component-writer.main.runtime'; @@ -123,3 +124,26 @@ describe('the workspace-root import preflight checks', () => { expect(() => main.throwForSymlinksInTheWay(componentFor(['docs/readme.md']))).to.throw('is a symbolic link'); }); }); + +describe('the destination of an already tracked workspace-root component', () => { + it('should be the workspace root, whatever path the caller derived without --path', async () => { + const writer = Object.create(ComponentWriter.prototype); + Object.assign(writer, { + // what composeRelativeComponentPath returns when no --path is given + writeToPath: 'my-scope/ws-root', + override: true, + writeConfig: false, + skipUpdatingBitMap: true, + existingComponentMap: { rootDir: WORKSPACE_ROOT_DIR, getRootDir: () => WORKSPACE_ROOT_DIR }, + bitMap: { getNestedRootDirs: () => [] }, + consumer: undefined, + component: { + id: { toString: () => 'my-scope/ws-root' }, + isLegacy: false, + files: [{ relative: 'README.md', basename: 'README.md', path: 'README.md', updatePaths: () => {} }], + }, + }); + await writer.populateComponentsFilesToWrite(); + expect(writer.writeToPath).to.equal(WORKSPACE_ROOT_DIR); + }); +}); From e88a8a65105e1438bbae3c429a2851be46da6db6 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:41:07 -0400 Subject: [PATCH 074/102] fix(component-writer): skip the live .bitmap in the root import preflight, as the write does --- .../component-writer.main.runtime.ts | 12 ++++++++---- .../component-writer/component-writer.spec.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index df26f2ceb0db..8ef7253e1020 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -383,9 +383,13 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace */ private filesThatWouldLand(component: ConsumerComponent) { const nestedRootDirs = this.consumer.bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR); - return component.files.filter( - (file) => !isOwnedByNestedComponent(pathNormalizeToLinux(file.relative), nestedRootDirs) - ); + return component.files.filter((file) => { + const relativePath = pathNormalizeToLinux(file.relative); + // the live map is never written from a versioned copy either, so a workspace whose own .bitmap + // is a symbolic link must not fail an import over a file that would not be touched + if (isWorkspaceMapFile(relativePath)) return false; + return !isOwnedByNestedComponent(relativePath, nestedRootDirs); + }); } private throwForSymlinksInTheWay(component: ConsumerComponent) { @@ -437,7 +441,7 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace const filesToOverwrite = this.filesThatWouldLand(component) .filter((file) => { const relativePath = pathNormalizeToLinux(file.relative); - if (isWorkspaceMapFile(relativePath) || generatedByInit.includes(relativePath)) return false; + if (generatedByInit.includes(relativePath)) return false; const absolutePath = this.consumer.toAbsolutePath(relativePath); // lstat rather than exists: a symlink in the way, dangling or not, is a conflict and not a path // to write through, and so is a directory. only a regular file is compared with the incoming copy. diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index aa974c9b49c9..74507018777d 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -118,6 +118,16 @@ describe('the workspace-root import preflight checks', () => { expect(() => main.throwForSymlinksInTheWay(componentFor(['packages/comp1/index.js']))).to.not.throw(); }); + it('should ignore the live .bitmap, which the write does not land either', async () => { + // a workspace whose own .bitmap is a symbolic link must not fail an import over a file the writer + // skips anyway (see isWorkspaceMapFile) + const target = path.join(workspacePath, 'elsewhere'); + await fs.ensureDir(target); + await fs.symlink(target, path.join(workspacePath, '.bitmap')); + const main = runtimeFor([], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['.bitmap', 'README.md']))).to.not.throw(); + }); + it('should still refuse a symlink on the way to a file the root does own', async () => { await fs.symlink(os.tmpdir(), path.join(workspacePath, 'docs')); const main = runtimeFor(['packages/comp1'], workspacePath); From 482c1eca937b7f8e7bb32b22c0b2bea6189cd9c7 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:41:11 -0400 Subject: [PATCH 075/102] fix(workspace-root): prefix the clone missing-components title with the error symbol --- scopes/workspace/workspace-root/clone.cmd.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scopes/workspace/workspace-root/clone.cmd.ts b/scopes/workspace/workspace-root/clone.cmd.ts index 0c9356eaf1d7..e3d69ffdc791 100644 --- a/scopes/workspace/workspace-root/clone.cmd.ts +++ b/scopes/workspace/workspace-root/clone.cmd.ts @@ -68,10 +68,10 @@ export function formatCloneResult(result: CloneResult, relativeDir: string): str `cloned ${result.rootId.toString()} into "${relativeDir}" with ${count} component${count === 1 ? '' : 's'}` ); const lane = result.laneId ? formatHint(`(the workspace is on lane ${result.laneId.toString()})`) : ''; - // missing state takes the error symbol, see cli-output-style-guide.md. the clone itself succeeded, - // which the summary above says, so the section is about the components rather than the command. + // missing state takes the error symbol, on the title as well as on the items - see the error + // sections of cli-output-style-guide.md. the clone itself succeeded, which the summary above says. const missing = formatSection( - 'components the root lists that are not on their remote', + `${errorSymbol} components the root lists that are not on their remote`, 'never exported, or exported elsewhere - the clone is without them', result.missing.map((missingId) => formatItem(missingId, errorSymbol)) ); From b6f861192a56cb70efa61b32c480e31e31f86d12 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:58:30 -0400 Subject: [PATCH 076/102] fix(tracker): apply the generated-files rule to an explicit main file too --- .../component/tracker/add-components.spec.ts | 25 ++++++++++++++++++ scopes/component/tracker/add-components.ts | 26 ++++++++++--------- .../tracker/track-workspace-root.spec.ts | 17 ++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/scopes/component/tracker/add-components.spec.ts b/scopes/component/tracker/add-components.spec.ts index 4b151e15d53d..a17c4ea6c65c 100644 --- a/scopes/component/tracker/add-components.spec.ts +++ b/scopes/component/tracker/add-components.spec.ts @@ -95,6 +95,31 @@ describe('the files bit add tracks', function () { }); }); + describe('a config file bit treats as generated, at the component root', () => { + const compWithTsconfig = { + 'comp1/index.js': 'module.exports = () => "comp1";\n', + 'comp1/tsconfig.json': '{}\n', + }; + const addWithTsconfigAsMain = async (trackAllFiles: boolean) => { + const { workspacePath, tracker } = await setup(compWithTsconfig, { trackAllFiles }); + return tracker.addForCLI({ + componentPaths: [path.join(workspacePath, 'comp1')], + id: 'comp1', + main: path.join(workspacePath, 'comp1/tsconfig.json'), + override: false, + }); + }; + it('should refuse it as a main file, rather than track what the next rescan drops', async () => { + // it used to fail further down with "main file tsconfig.json was removed from ", which + // sends the user to "bit remove" for a component they are adding + await expectToReject(() => addWithTsconfigAsMain(false), 'was excluded from file list'); + }); + it('should accept it with trackAllFiles on, where the rescan keeps it', async () => { + const results = await addWithTsconfigAsMain(true); + expect(results.addedComponents[0].files.map((file) => file.relativePath)).to.include('tsconfig.json'); + }); + }); + describe('a file bit generated, which carries its banner', () => { const compWithGeneratedFile = { 'comp1/index.js': 'module.exports = () => "comp1";\n', diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 855093d3ad0f..362d941d3fd9 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -254,7 +254,7 @@ export default class AddComponents { // only until a more specific component is added for it: the component being added wins, and the // root subtracts the new root-dir from its own file-set on the next scan. the one file it cannot // give away is its main file - without it, it fails to load from the next scan on. - const workspaceRootMap = this.bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); + const workspaceRootMap = this.bitMap.getWorkspaceRootMap(); const isWorkspaceRoot = component.trackDir === WORKSPACE_ROOT_DIR; if (workspaceRootMap && !isWorkspaceRoot && !parsedBitId.isEqualWithoutVersion(workspaceRootMap.id)) { throwForTakingWorkspaceRootMainFile(workspaceRootMap, parsedBitId, pathNormalizeToLinux(component.trackDir)); @@ -597,11 +597,12 @@ you can add the directory these files are located at and it'll change the root d matchesNotIgnored.map(relativeToComponent) ) ); - const filteredMatches = matchesNotIgnored.filter( - (match) => - keptByOwnIgnoreFile.has(relativeToComponent(match)) && - (this.consumer.config.trackAllFiles || !generatedAtRoot.has(match)) - ); + // the rules the rescan applies too (see getFilesByDir), so the add-time file-set matches it. the + // main file is checked against the same predicate below - it is put back after this filtering. + const isTrackable = (match: PathLinux) => + keptByOwnIgnoreFile.has(relativeToComponent(match)) && + (this.consumer.config.trackAllFiles || !generatedAtRoot.has(match)); + const filteredMatches = matchesNotIgnored.filter(isTrackable); if (!filteredMatches.length) { throw new NoFiles(matches); @@ -612,13 +613,14 @@ you can add the directory these files are located at and it'll change the root d }); const resolvedMainFile = this._addMainFileToFiles(filteredMatchedFiles); // that puts the main file back into the list after the filtering above, and checks it against the - // workspace ignore rules only - so the component's own ignore file is applied to it here. without - // it the add fails further down on a main file the rescan already dropped, saying it was removed. + // workspace ignore rules only. without applying the rest of them here, the add fails further down + // on a main file the rescan already dropped, saying it was removed - the config files bit treats + // as generated (tsconfig.json and friends) reach it that way, as does a component ignore file. if (resolvedMainFile) { const mainNormalized = pathNormalizeToLinux(resolvedMainFile); - const excludedByOwnIgnoreFile = - matchesNotIgnored.includes(mainNormalized) && !keptByOwnIgnoreFile.has(relativeToComponent(mainNormalized)); - if (excludedByOwnIgnoreFile) throw new ExcludedMainFile(relativeToComponent(mainNormalized)); + if (matchesNotIgnored.includes(mainNormalized) && !isTrackable(mainNormalized)) { + throw new ExcludedMainFile(relativeToComponent(mainNormalized)); + } } const absoluteComponentPath = pathNormalizeToLinux(path.resolve(componentPath)); @@ -905,7 +907,7 @@ export async function addMultipleFromResolvedTrackData( : await filterResolvedFiles(rootDir, workspace.path, files, gitIgnore, trackAllFiles); // a nested component may take any file from a tracked workspace root but its main file, the // rule "bit add" applies. a root tracked later in the same call scans around this component. - const workspaceRootMap = bitMap.components.find((componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR); + const workspaceRootMap = bitMap.getWorkspaceRootMap(); if (!isWorkspaceRoot && workspaceRootMap) throwForTakingWorkspaceRootMainFile(workspaceRootMap, idToTrack, rootDir); const componentMap = bitMap.addComponent({ componentId: idToTrack, diff --git a/scopes/component/tracker/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts index e94baa4b03b8..7a24b1dc53bf 100644 --- a/scopes/component/tracker/track-workspace-root.spec.ts +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -173,6 +173,23 @@ describe('tracking the workspace root', function () { 'main file of the workspace-root component' ); }); + it('should allow it when the root belongs to another lane, it owns nothing here', async () => { + // the root stays in .bitmap so a switch back can restore it. guarding its main file meanwhile + // would reject an add that is fine on this lane + const rootMap = tracked.workspace.consumer.bitMap.getWorkspaceRootMap(); + expect(rootMap).to.not.be.undefined; + rootMap!.isAvailableOnCurrentLane = false; + try { + const results = await tracked.tracker.addForCLI({ + componentPaths: [inWs(tracked, 'packages/comp1')], + id: 'comp1-on-lane', + override: false, + }); + expect(results.addedComponents).to.have.lengthOf(1); + } finally { + rootMap!.isAvailableOnCurrentLane = true; + } + }); it('should refuse even when the nested component ignores that file, its directory is what the root loses', async () => { fs.outputFileSync(path.join(tracked.workspacePath, 'packages/comp1/.bitignore'), 'index.js\n'); fs.outputFileSync(path.join(tracked.workspacePath, 'packages/comp1/other.js'), ''); From 498fbd395a4265555ec89391d00b57ee8e38f714 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:58:34 -0400 Subject: [PATCH 077/102] fix(bit-map): one lane-aware lookup for the component that owns the workspace root --- components/legacy/bit-map/bit-map.ts | 14 +++++++ .../workspace-root-data.spec.ts | 38 ++++++++++--------- .../workspace-root/workspace-root-data.ts | 6 +-- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 318e2528d5ef..ea7563581b45 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -242,6 +242,20 @@ export class BitMap { delete componentsJson[LANE_KEY]; } + /** + * the entry of the component that owns the workspace root (rootDir "."), if this workspace has one + * right now. a root created on another lane, or removed, stays in `.bitmap` so a switch back can + * restore it - it owns nothing in the meantime, so the guards that protect the root skip it. + */ + getWorkspaceRootMap(): ComponentMap | undefined { + return this.components.find( + (componentMap) => + componentMap.rootDir === WORKSPACE_ROOT_DIR && + componentMap.isAvailableOnCurrentLane && + !componentMap.isRemoved() + ); + } + /** * root-dirs of the components nested inside the given root-dir. their files belong to them, so * the containing component must subtract them from its own file-set. diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts index 6ea5acbb209c..d0107aafbc3d 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.spec.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { ComponentID } from '@teambit/component-id'; -import type { BitMap } from '@teambit/legacy.bit-map'; +import { BitMap } from '@teambit/legacy.bit-map'; +import { Extensions } from '@teambit/legacy.constants'; import { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; import { findWorkspaceRootMap, @@ -14,29 +15,32 @@ const rootId = ComponentID.fromString('my-scope/my-root@0.0.7'); describe('workspace-root data', () => { describe('findWorkspaceRootMap', () => { - const entry = (id: ComponentID, rootDir: string, overrides: Record = {}) => ({ - id, - rootDir, - isAvailableOnCurrentLane: true, - isRemoved: () => false, - ...overrides, - }); - const bitMapOf = (entries: Record[]) => ({ components: entries }) as unknown as BitMap; - it('should return the entry tracked at the workspace root', () => { - const bitMap = bitMapOf([entry(ComponentID.fromString('my-scope/comp1'), 'comp1'), entry(rootId, '.')]); + // a real BitMap rather than a stand-in: the rule lives on it (getWorkspaceRootMap), and a mock + // that answers isRemoved() itself would only be testing the mock + const bitMapWith = async (entries: Record) => { + const bitMap = await BitMap.load(__dirname, ''); + bitMap.loadComponents(entries, 'my-scope'); + return bitMap; + }; + const rootEntry = { name: 'my-root', scope: 'my-scope', version: '0.0.7', mainFile: 'README.md', rootDir: '.' }; + const nestedEntry = { name: 'comp1', scope: 'my-scope', version: '0.0.1', mainFile: 'index.js', rootDir: 'comp1' }; + it('should return the entry tracked at the workspace root', async () => { + const bitMap = await bitMapWith({ 'my-scope/comp1': nestedEntry, 'my-scope/my-root': rootEntry }); expect(findWorkspaceRootMap(bitMap)?.id.toString()).to.equal(rootId.toString()); }); - it('should return undefined when no component owns the workspace root', () => { - const bitMap = bitMapOf([entry(ComponentID.fromString('my-scope/comp1'), 'comp1')]); + it('should return undefined when no component owns the workspace root', async () => { + const bitMap = await bitMapWith({ 'my-scope/comp1': nestedEntry }); expect(findWorkspaceRootMap(bitMap)).to.be.undefined; }); - it('should ignore a root of another lane, this lane is rootless', () => { + it('should ignore a root of another lane, this lane is rootless', async () => { // it stays in .bitmap so a switch back can restore it. snapping here would otherwise tag it along - const bitMap = bitMapOf([entry(rootId, '.', { isAvailableOnCurrentLane: false })]); + const bitMap = await bitMapWith({ 'my-scope/my-root': rootEntry }); + bitMap.components[0].isAvailableOnCurrentLane = false; expect(findWorkspaceRootMap(bitMap)).to.be.undefined; }); - it('should ignore a removed root', () => { - const bitMap = bitMapOf([entry(rootId, '.', { isRemoved: () => true })]); + it('should ignore a removed root', async () => { + const bitMap = await bitMapWith({ 'my-scope/my-root': rootEntry }); + bitMap.components[0].config = { [Extensions.remove]: { removed: true } }; expect(findWorkspaceRootMap(bitMap)).to.be.undefined; }); }); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts index 6b3299d318b4..1663290267fd 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -1,6 +1,5 @@ import { ComponentID } from '@teambit/component-id'; import type { BitMap, ComponentMap } from '@teambit/legacy.bit-map'; -import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; import type { ExtensionDataList } from '@teambit/legacy.extension-data'; import { ExtensionDataEntry } from '@teambit/legacy.extension-data'; import { WorkspaceRootAspect } from './workspace-root.aspect'; @@ -32,10 +31,7 @@ export type WorkspaceRootData = { * it is not this workspace's root though, and tagging or snapping would otherwise bring it along. */ export function findWorkspaceRootMap(bitMap: BitMap): ComponentMap | undefined { - return bitMap.components.find( - (componentMap) => - componentMap.rootDir === WORKSPACE_ROOT_DIR && componentMap.isAvailableOnCurrentLane && !componentMap.isRemoved() - ); + return bitMap.getWorkspaceRootMap(); } function findData(extensions: ExtensionDataList): WorkspaceRootData | undefined { From 579f999437c7ee201af6a1a4d4747e08222f15ea Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 13:58:36 -0400 Subject: [PATCH 078/102] docs(watcher): say which generated configs the ignore patterns do not cover --- scopes/workspace/watcher/watcher.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index a22dfaed1e32..3e9b59160335 100644 --- a/scopes/workspace/watcher/watcher.ts +++ b/scopes/workspace/watcher/watcher.ts @@ -1146,7 +1146,11 @@ export class Watcher { * component never lists them - so reporting an edit of one only produces "inside the component but * configured to be ignored". a workspace-root component owns the whole tree, which makes the * manifests and configs at the workspace root the common case. the root-only names stay unprefixed on - * purpose: they are ignored where a component's rootDir is, and here that is the workspace root. + * purpose: a rescan drops them at a component's rootDir, and prefixing them with `**\/` would hide a + * config file deeper inside a component, which is source. so only the workspace root is covered here + * - editing e.g. `packages/comp/tsconfig.json` still reaches the watcher and is then found to be + * outside the component's file-set. covering every component root needs these patterns rebuilt + * whenever a component is added or moved, which the watcher does not do today. */ export function watchIgnorePatterns(relScopePath: string, trackAllFiles?: boolean): string[] { const generated = trackAllFiles From 44c56fa873e55c0875a3a4dce40cb617bc1c7692 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 14:23:37 -0400 Subject: [PATCH 079/102] fix(install): clear the install context in a finally, a caught failure left it set --- .../workspace/install/install.main.runtime.ts | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 0940525f56d2..db0d2d27a953 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -204,38 +204,45 @@ export class InstallMain { // set workspace in install context this.workspace.inInstallContext = true; this.workspace.inInstallAfterPmContext = false; - if (packages && packages.length) { - await this._addPackages(packages, options); - } - if (options?.addMissingPeers) { - const compDirMap = await this.getComponentsDirectory([]); - const mergedRootPolicy = this.dependencyResolver.getWorkspacePolicy(); - const depsFilterFn = await this.generateFilterFnForDepsFromLocalRemote(); - const pmInstallOptions: PackageManagerInstallOptions = { - dedupe: options?.dedupe, - copyPeerToRuntimeOnRoot: options?.copyPeerToRuntimeOnRoot ?? true, - copyPeerToRuntimeOnComponents: options?.copyPeerToRuntimeOnComponents ?? false, - dependencyFilterFn: depsFilterFn, - overrides: this.dependencyResolver.config.overrides, - hoistPatterns: this.dependencyResolver.config.hoistPatterns, - packageImportMethod: this.dependencyResolver.config.packageImportMethod, - }; - const missingPeers = await this.dependencyResolver.getMissingPeerDependencies( - this.workspace.path, - mergedRootPolicy, - compDirMap, - pmInstallOptions - ); - if (missingPeers) { - const missingPeerPackages = Object.entries(missingPeers).map(([peerName, range]) => `${peerName}@${range}`); - await this._addPackages(missingPeerPackages, options); - } else { - this.logger.console('No missing peer dependencies found.'); + let res: ComponentMap; + try { + if (packages && packages.length) { + await this._addPackages(packages, options); } + if (options?.addMissingPeers) { + const compDirMap = await this.getComponentsDirectory([]); + const mergedRootPolicy = this.dependencyResolver.getWorkspacePolicy(); + const depsFilterFn = await this.generateFilterFnForDepsFromLocalRemote(); + const pmInstallOptions: PackageManagerInstallOptions = { + dedupe: options?.dedupe, + copyPeerToRuntimeOnRoot: options?.copyPeerToRuntimeOnRoot ?? true, + copyPeerToRuntimeOnComponents: options?.copyPeerToRuntimeOnComponents ?? false, + dependencyFilterFn: depsFilterFn, + overrides: this.dependencyResolver.config.overrides, + hoistPatterns: this.dependencyResolver.config.hoistPatterns, + packageImportMethod: this.dependencyResolver.config.packageImportMethod, + }; + const missingPeers = await this.dependencyResolver.getMissingPeerDependencies( + this.workspace.path, + mergedRootPolicy, + compDirMap, + pmInstallOptions + ); + if (missingPeers) { + const missingPeerPackages = Object.entries(missingPeers).map(([peerName, range]) => `${peerName}@${range}`); + await this._addPackages(missingPeerPackages, options); + } else { + this.logger.console('No missing peer dependencies found.'); + } + } + await pMapSeries(this.preInstallSlot.values(), (fn) => fn(options)); // import objects if not disabled in options + res = await this._installModules(options); + } finally { + // in a finally: a caller that catches the failure and carries on - "bit clone" keeps the + // workspace it made when the install fails - would otherwise leave the flag set, and the + // component loader suppresses missing modules and aspect-loading errors while it is + this.workspace.inInstallContext = false; } - await pMapSeries(this.preInstallSlot.values(), (fn) => fn(options)); // import objects if not disabled in options - const res = await this._installModules(options); - this.workspace.inInstallContext = false; await this.ipcEvents.publishIpcEvent('onPostInstall'); From f51d709397a1df55be77b146369b700b48db32cd Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 14:23:40 -0400 Subject: [PATCH 080/102] fix(component-writer): check the generated config file for a symlink at the workspace root --- .../component-writer.main.runtime.ts | 12 ++++++++---- .../component-writer/component-writer.spec.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 8ef7253e1020..b2c735794f57 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -293,7 +293,7 @@ export class ComponentWriterMain { if (componentRootDir === WORKSPACE_ROOT_DIR || existingComponentMap?.rootDir === WORKSPACE_ROOT_DIR) { this.throwForNonWorkspaceRootComponent(component); // the symlink rule is about where a write lands, and --track-only writes nothing (skipWritingToFs) - if (!opts.skipWritingToFs) this.throwForSymlinksInTheWay(component); + if (!opts.skipWritingToFs) this.throwForSymlinksInTheWay(component, opts.writeConfig); } // with --write-to-empty-dir, dir-conflict resolution is deferred to relocateOccupiedDirs() so it runs after the // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. @@ -392,10 +392,14 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace }); } - private throwForSymlinksInTheWay(component: ConsumerComponent) { + private throwForSymlinksInTheWay(component: ConsumerComponent, writeConfig?: boolean) { const pathsInTheWay = new Set(); - this.filesThatWouldLand(component).forEach((file) => { - const segments = pathNormalizeToLinux(file.relative).split('/'); + const landing = this.filesThatWouldLand(component).map((file) => pathNormalizeToLinux(file.relative)); + // the config file is generated rather than versioned, so it is not among the component's files - + // it still lands at the root, and this rule is about where a write goes + if (writeConfig) landing.push(COMPONENT_CONFIG_FILE_NAME); + landing.forEach((relativePath) => { + const segments = relativePath.split('/'); segments.forEach((_, index) => pathsInTheWay.add(segments.slice(0, index + 1).join('/'))); }); pathsInTheWay.forEach((pathInTheWay) => { diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 74507018777d..997d400d87df 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -128,6 +128,24 @@ describe('the workspace-root import preflight checks', () => { expect(() => main.throwForSymlinksInTheWay(componentFor(['.bitmap', 'README.md']))).to.not.throw(); }); + it('should refuse a symlinked component.json when the config file is written', async () => { + // it is generated rather than versioned, so it is not among the component's files - it still + // lands at the root, and the writer forces override on it + const target = path.join(workspacePath, 'elsewhere'); + await fs.ensureDir(target); + await fs.symlink(target, path.join(workspacePath, 'component.json')); + const main = runtimeFor([], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']), true)).to.throw('is a symbolic link'); + }); + + it('should leave it alone when no config file is written', async () => { + const target = path.join(workspacePath, 'elsewhere'); + await fs.ensureDir(target); + await fs.symlink(target, path.join(workspacePath, 'component.json')); + const main = runtimeFor([], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']), false)).to.not.throw(); + }); + it('should still refuse a symlink on the way to a file the root does own', async () => { await fs.symlink(os.tmpdir(), path.join(workspacePath, 'docs')); const main = runtimeFor(['packages/comp1'], workspacePath); From eaffb84aac9cd17ad00ce1d9d5e474b2cb279b23 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 14:23:44 -0400 Subject: [PATCH 081/102] test(e2e): drop a root-manifest assertion the clone flow already proves --- e2e/harmony/add-harmony.e2e.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 3f18878250a6..19d42031638d 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -454,9 +454,6 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# workspace root\n'); helper.command.addComponent('.', { i: 'ws-root' }); }); - it('should track the package.json of the workspace root', () => { - expect(helper.command.getComponentFiles('ws-root')).to.include('package.json'); - }); describe('cloning the workspace from its root component', () => { // bit clone runs outside a workspace and needs no bit init. the remote is registered globally, as // there is no workspace to register it in yet, and the clone lands in the emptied dir. From 66c26e784db9941346aa2977122353e07c73e8e8 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 14:45:05 -0400 Subject: [PATCH 082/102] test(e2e): cover cloning a lane with a version pinned on the root id --- e2e/harmony/add-harmony.e2e.ts | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 19d42031638d..5c3ea3414a06 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -519,10 +519,12 @@ describe('add command on Harmony', function () { let comp2MainHead: string; let comp1LaneHead: string; let rootLaneHead: string; + let rootMainHead: string; before(() => { helper.scopeHelper.setWorkspaceWithRemoteScope(); helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.fs.outputFile('comp2/index.js', 'module.exports = () => "comp2";\n'); + helper.fs.outputFile('README.md', '# on main\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.command.addComponent('comp2', { i: 'comp2' }); helper.command.addComponent('.', { i: 'ws-root' }); @@ -539,6 +541,13 @@ describe('add command on Harmony', function () { helper.command.export(); comp1LaneHead = helper.command.getHeadOfLane('dev', 'comp1'); rootLaneHead = helper.command.getHeadOfLane('dev', 'ws-root'); + // main moves on after the lane branched off it, so the root version pinned further down is not + // an ancestor of the lane head - it is on main and nowhere in the lane's history. + helper.command.switchLocalLane('main'); + helper.fs.outputFile('README.md', '# on main again\n'); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + helper.command.export(); + rootMainHead = helper.command.getHead('ws-root'); helper.scopeHelper.cleanWorkspace(); helper.scopeHelper.addRemoteScope(undefined, undefined, true); helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root . --lane ${helper.scopes.remote}/dev -x`); @@ -560,5 +569,39 @@ describe('add command on Harmony', function () { it('should come out clean, the root converging on the .bitmap the clone built', () => { helper.command.expectStatusToBeClean(); }); + describe('with a version on the root id, which pins the root files only', () => { + // the two options mean different things: the version says which root files to write, the lane + // says where the members come from. the version pinned here is main's head, which the lane + // branched away from before it was made - so it is reachable from main and from nowhere on the + // lane, and a clone that let the lane decide the root too would not find it. + let clonePath: string; + before(() => { + clonePath = path.join(helper.scopes.e2eDir, 'pinned-root'); + // the clone makes a workspace here, and it refuses to run inside one - so a rerun of this + // file against the same e2e directory starts from nothing + fs.removeSync(clonePath); + helper.command.runCmd( + `bit clone ${helper.scopes.remote}/ws-root@${rootMainHead} ${clonePath} --lane ${helper.scopes.remote}/dev -x`, + helper.scopes.e2eDir + ); + }); + it('should write the root files of the version asked for, not of its head on the lane', () => { + expect(rootMainHead).to.not.equal(rootLaneHead); + expect(helper.bitMap.read(path.join(clonePath, '.bitmap'))['ws-root'].version).to.equal(rootMainHead); + // the three snaps gave this file three different contents, so it says which one was written + expect(path.join(clonePath, 'README.md')).to.be.a.file().with.content('# on main again\n'); + }); + it('should still take the members at their heads on the lane', () => { + const bitMap = helper.bitMap.read(path.join(clonePath, '.bitmap')); + expect(bitMap.comp1.version).to.equal(comp1LaneHead); + expect(bitMap.comp2.version).to.equal(comp2MainHead); + }); + it('should still come out on the lane', () => { + expect(helper.bitMap.read(path.join(clonePath, '.bitmap'))._bit_lane.id).to.deep.equal({ + name: 'dev', + scope: helper.scopes.remote, + }); + }); + }); }); }); From de95944217e4798507ece466bbb627d0bcd1663d Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 14:45:07 -0400 Subject: [PATCH 083/102] fix(workspace-root): refuse an absolute rootDir that resolves inside the workspace --- scopes/workspace/workspace-root/clone.spec.ts | 7 +++++++ scopes/workspace/workspace-root/clone.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index a58f79c41e77..b6b4e97d4f89 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -36,6 +36,13 @@ describe('resolveComponentDir', () => { resolveComponentDir(workspacePath, { id: 'a', rootDir: path.resolve(os.tmpdir(), 'elsewhere') }); expect(resolve).to.throw('not a directory inside the workspace'); }); + it('should refuse an absolute root-dir that points inside the workspace too', () => { + // it resolves to a directory of this workspace, so every check but the absolute one lets it + // through - and it is still the path of the machine the root was snapped on + const resolve = () => + resolveComponentDir(workspacePath, { id: 'a', rootDir: path.join(workspacePath, 'comps', 'a') }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); it('should refuse the workspace root itself, only the root component owns it', () => { const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir: '.' }); expect(resolve).to.throw('not a directory inside the workspace'); diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 04f4cebb92bc..a6dc8b7c0940 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -282,7 +282,11 @@ export async function cloneWorkspace( * `.bitmap` of the current schema has. */ export function resolveComponentDir(workspacePath: string, entry: VersionedBitmapEntry): string { - const target = entry.rootDir ? path.resolve(workspacePath, entry.rootDir) : undefined; + const { rootDir } = entry; + // the absolute check goes before the resolve: an absolute path that happens to sit under this + // workspace resolves to an ordinary relative one and would pass every check below, though it is + // still a directory of the machine the root was snapped on rather than one of this workspace. + const target = rootDir && !path.isAbsolute(rootDir) ? path.resolve(workspacePath, rootDir) : undefined; const relative = target ? path.relative(workspacePath, target) : undefined; // a leading ".." is only a way out when it is the whole segment: a directory may be named "..cache" const climbsOut = relative === '..' || relative?.startsWith(`..${path.sep}`); From 54d7da7dd8c9158b25a73c89a811e12effe9a4ed Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 15:22:11 -0400 Subject: [PATCH 084/102] fix(workspace-root): resolve a symlinked ancestor before checking for a workspace above the clone target --- scopes/workspace/workspace-root/clone.spec.ts | 43 ++++++++++++++++++- scopes/workspace/workspace-root/clone.ts | 33 +++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index b6b4e97d4f89..ed525457843c 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -3,7 +3,7 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { ComponentID } from '@teambit/component-id'; -import { ensureEmptyDir, resolveClonePath, resolveComponentDir } from './clone'; +import { ensureEmptyDir, resolveClonePath, resolveComponentDir, resolveThroughExistingAncestors } from './clone'; import { WorkspaceRootMain } from './workspace-root.main.runtime'; describe('resolveClonePath', () => { @@ -58,6 +58,47 @@ describe('resolveComponentDir', () => { }); }); +describe('resolveThroughExistingAncestors', () => { + let base: string; + beforeEach(async () => { + // realpath'd, so that the assertions below compare against what the resolve returns - on macOS + // the temp directory is itself reached through a link + base = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bit-clone-anc-'))); + }); + afterEach(async () => { + await fs.remove(base); + }); + + it('should resolve a symbolic link above an absent destination, the writes go through it', async () => { + const real = path.join(base, 'real'); + await fs.ensureDir(real); + await fs.symlink(real, path.join(base, 'link')); + expect(await resolveThroughExistingAncestors(path.join(base, 'link', 'ws'))).to.equal(path.join(real, 'ws')); + }); + + it('should resolve it through several levels that do not exist yet', async () => { + const real = path.join(base, 'real'); + await fs.ensureDir(real); + await fs.symlink(real, path.join(base, 'link')); + expect(await resolveThroughExistingAncestors(path.join(base, 'link', 'a', 'b'))).to.equal( + path.join(real, 'a', 'b') + ); + }); + + it('should leave a destination with no link above it as it is, the ordinary case', async () => { + expect(await resolveThroughExistingAncestors(path.join(base, 'ws'))).to.equal(path.join(base, 'ws')); + }); + + it('should not resolve a link at the destination itself, which is refused rather than followed', async () => { + // ensureEmptyDir is what refuses it, and it only can while the last segment is left alone + const real = path.join(base, 'real'); + await fs.ensureDir(real); + const link = path.join(base, 'link'); + await fs.symlink(real, link); + expect(await resolveThroughExistingAncestors(link)).to.equal(link); + }); +}); + describe('ensureEmptyDir', () => { const expectToReject = async (dir: string, message: string) => { try { diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index a6dc8b7c0940..58e2efa16ea6 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -231,7 +231,7 @@ export async function cloneWorkspace( options: CloneOptions, loadBit: LoadBit ): Promise { - const workspacePath = resolveClonePath(dir, rootId); + const workspacePath = await resolveThroughExistingAncestors(resolveClonePath(dir, rootId)); await throwForWorkspaceAbove(workspacePath); const createdDir = await ensureEmptyDir(workspacePath); const originalCwd = process.cwd(); @@ -306,6 +306,37 @@ export function resolveClonePath(dir: string | undefined, rootId: ComponentID): return path.resolve(dir || rootId.name); } +/** + * the destination with the directories above it resolved through any symbolic link, and its own last + * segment left as it is. + * + * a link above the target redirects everything that follows - the directory creation, the init, the + * files, and the cleanup of a failed clone - while the lexical path says nothing about it, so the + * workspace check would look in one place and the writes land in another, inside someone else's + * workspace. the last segment is deliberately not resolved: a link *at* the destination is refused + * rather than followed, which is ensureEmptyDir's rule. + */ +export async function resolveThroughExistingAncestors(dirPath: string): Promise { + const parent = path.dirname(dirPath); + if (parent === dirPath) return dirPath; + return path.join(await realpathOfNearestExisting(parent), path.basename(dirPath)); +} + +/** + * the deepest part of the path that exists, resolved, with the part that does not exist yet appended + * as it is. the destination of a clone is normally absent, so there is nothing to resolve on it. + */ +async function realpathOfNearestExisting(dirPath: string): Promise { + const parent = path.dirname(dirPath); + if (parent === dirPath) return dirPath; + try { + return await fs.realpath(dirPath); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + return path.join(await realpathOfNearestExisting(parent), path.basename(dirPath)); + } +} + /** * a clone makes its own workspace. the init below takes the nearest workspace at or above the target * instead of making one when there is a workspace above it, and the clone then writes its components From 6705cd93bd8f1b4a630c0130c105bed33ea541ba Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 15:22:14 -0400 Subject: [PATCH 085/102] test: move the symlink preflight variants off the e2e tier --- e2e/harmony/add-harmony.e2e.ts | 29 ++++--------------- .../component-writer/component-writer.spec.ts | 15 ++++++++++ 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 5c3ea3414a06..30c316819f98 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -295,7 +295,7 @@ describe('add command on Harmony', function () { expect(path.join(helper.scopes.localPath, 'README.md')).to.be.a.file().with.content('# my own readme\n'); }); }); - describe('importing it onto the root with a directory or a dangling symlink in the way', () => { + describe('importing it onto the root with a directory or a symlink in the way', () => { before(() => { helper.scopeHelper.reInitWorkspace(); helper.scopeHelper.addRemoteScope(); @@ -305,16 +305,11 @@ describe('add command on Harmony', function () { const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); expect(cmd).to.throw('use --override'); }); - it('should refuse a dangling symlink rather than write through it', () => { - fs.rmdirSync(path.join(helper.scopes.localPath, 'README.md')); - const target = path.join(helper.scopes.localPath, 'missing-target'); - fs.symlinkSync(target, path.join(helper.scopes.localPath, 'README.md')); - const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path .'); - expect(cmd).to.throw('symbolic link'); - expect(target).to.not.be.a.path(); - }); + // the variants of the rule itself - a dangling link, a link at the destination rather than above + // it - are in component-writer.spec.ts, against the preflight directly. what needs the command + // is that it runs at all and that --override does not waive it, which the case below proves. it('should refuse a symlinked ancestor directory even with --override, rather than write through it', () => { - fs.unlinkSync(path.join(helper.scopes.localPath, 'README.md')); + fs.rmdirSync(path.join(helper.scopes.localPath, 'README.md')); const outside = path.join(helper.scopes.localPath, '..', `outside-${path.basename(helper.scopes.localPath)}`); fs.mkdirSync(outside); fs.symlinkSync(outside, path.join(helper.scopes.localPath, 'docs')); @@ -323,20 +318,6 @@ describe('add command on Harmony', function () { expect(path.join(outside, 'guide.md')).to.not.be.a.path(); fs.removeSync(outside); }); - it('should refuse a symlinked destination even with --override, rather than write through it', () => { - fs.unlinkSync(path.join(helper.scopes.localPath, 'docs')); - const outsideFile = path.join( - helper.scopes.localPath, - '..', - `outside-${path.basename(helper.scopes.localPath)}.md` - ); - fs.writeFileSync(outsideFile, 'theirs\n'); - fs.symlinkSync(outsideFile, path.join(helper.scopes.localPath, 'README.md')); - const cmd = () => helper.command.importComponentWithoutInstall('ws-root', '--path . --override'); - expect(cmd).to.throw('symbolic link'); - expect(outsideFile).to.be.a.file().with.content('theirs\n'); - fs.removeSync(outsideFile); - }); }); describe('importing an ordinary component onto the root', () => { before(() => { diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 997d400d87df..bec5c39b215e 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -151,6 +151,21 @@ describe('the workspace-root import preflight checks', () => { const main = runtimeFor(['packages/comp1'], workspacePath); expect(() => main.throwForSymlinksInTheWay(componentFor(['docs/readme.md']))).to.throw('is a symbolic link'); }); + + it('should refuse a symlink at the file itself, not only above it', async () => { + const outsideFile = path.join(workspacePath, 'theirs.md'); + await fs.writeFile(outsideFile, 'theirs\n'); + await fs.symlink(outsideFile, path.join(workspacePath, 'README.md')); + const main = runtimeFor([], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']))).to.throw('is a symbolic link'); + }); + + it('should refuse a dangling symlink, whose target does not exist, rather than write through it', async () => { + // a stat would say there is nothing there and let the write create the target; the check lstats + await fs.symlink(path.join(workspacePath, 'missing-target'), path.join(workspacePath, 'README.md')); + const main = runtimeFor([], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']))).to.throw('is a symbolic link'); + }); }); describe('the destination of an already tracked workspace-root component', () => { From 363b7abac2aa8fe4e951df1e448f1800ed41c557 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 15:38:52 -0400 Subject: [PATCH 086/102] fix(workspace-root): remove the directories a failed clone created on the way to its target --- scopes/workspace/workspace-root/clone.spec.ts | 35 ++++++++++++++++++- scopes/workspace/workspace-root/clone.ts | 20 +++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index ed525457843c..d49d2785feb2 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -3,7 +3,13 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { ComponentID } from '@teambit/component-id'; -import { ensureEmptyDir, resolveClonePath, resolveComponentDir, resolveThroughExistingAncestors } from './clone'; +import { + ensureEmptyDir, + resolveClonePath, + resolveComponentDir, + resolveThroughExistingAncestors, + topmostAbsentDir, +} from './clone'; import { WorkspaceRootMain } from './workspace-root.main.runtime'; describe('resolveClonePath', () => { @@ -99,6 +105,33 @@ describe('resolveThroughExistingAncestors', () => { }); }); +describe('topmostAbsentDir', () => { + let base: string; + beforeEach(async () => { + base = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'bit-clone-top-'))); + }); + afterEach(async () => { + await fs.remove(base); + }); + + it('should be undefined for a destination that is already there, nothing was made for it', async () => { + expect(await topmostAbsentDir(base)).to.equal(undefined); + }); + + it('should be the destination itself when only it is missing', async () => { + expect(await topmostAbsentDir(path.join(base, 'ws'))).to.equal(path.join(base, 'ws')); + }); + + it('should be the highest level made on the way to it, so removing that one takes the rest', async () => { + // "clone into new-parent/ws": removing only "ws" would leave "new-parent" standing empty + expect(await topmostAbsentDir(path.join(base, 'new-parent', 'ws'))).to.equal(path.join(base, 'new-parent')); + }); + + it('should reach up through several missing levels', async () => { + expect(await topmostAbsentDir(path.join(base, 'a', 'b', 'c'))).to.equal(path.join(base, 'a')); + }); +}); + describe('ensureEmptyDir', () => { const expectToReject = async (dir: string, message: string) => { try { diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 58e2efa16ea6..48002cffcacc 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -233,6 +233,9 @@ export async function cloneWorkspace( ): Promise { const workspacePath = await resolveThroughExistingAncestors(resolveClonePath(dir, rootId)); await throwForWorkspaceAbove(workspacePath); + // before anything is made: ensureDir below creates every missing level, not only the destination, + // so this is the point a failure has to undo from + const topmostCreated = await topmostAbsentDir(workspacePath); const createdDir = await ensureEmptyDir(workspacePath); const originalCwd = process.cwd(); try { @@ -263,9 +266,10 @@ export async function cloneWorkspace( return await new WorkspaceCloner(harmony, workspacePath).clone(rootId, options); } catch (err) { // leave the directory before removing it, and leave nothing half-made behind: it was empty or - // absent to begin with + // absent to begin with. removing the topmost level that was made here takes the ones below it + // along, so "clone into new-parent/ws" does not leave an empty "new-parent" standing. process.chdir(originalCwd); - if (createdDir) await fs.remove(workspacePath); + if (createdDir) await fs.remove(topmostCreated || workspacePath); else await fs.emptyDir(workspacePath); throw err; } finally { @@ -322,6 +326,18 @@ export async function resolveThroughExistingAncestors(dirPath: string): Promise< return path.join(await realpathOfNearestExisting(parent), path.basename(dirPath)); } +/** + * the highest directory on the way to the destination that does not exist yet, or undefined when the + * destination is already there. what a failed clone removes, so that the levels made on the way to it + * go too rather than being left standing empty. + */ +export async function topmostAbsentDir(dirPath: string): Promise { + if (await fs.pathExists(dirPath)) return undefined; + const parent = path.dirname(dirPath); + if (parent === dirPath) return dirPath; + return (await topmostAbsentDir(parent)) || dirPath; +} + /** * the deepest part of the path that exists, resolved, with the part that does not exist yet appended * as it is. the destination of a clone is normally absent, so there is nothing to resolve on it. From ac10a7fde1e39d6ff47b20378c8bc4de602436ce Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 15:38:54 -0400 Subject: [PATCH 087/102] test(component-writer): cover that the batch write leaves the workspace root at "." --- .../component-writer/component-writer.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index bec5c39b215e..dc5bfa01eed2 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -168,6 +168,41 @@ describe('the workspace-root import preflight checks', () => { }); }); +describe('writing the workspace-root component in the same batch as a component nested in it', () => { + /** + * the batch goes through fixDirsIfNested, which moves a component aside when another one is to be + * written inside it. the root must come out of it untouched: it owns "." by definition, and the + * components below it are the normal case rather than a collision. + */ + function runFixDirs(writeToPaths: string[], existingRootDirs: string[] = []) { + const main = Object.create(ComponentWriterMain.prototype); + Object.defineProperty(main, 'workspace', { value: { bitMap: { getAllRootDirs: () => existingRootDirs } } }); + const writers = writeToPaths.map((writeToPath) => ({ + writeToPath, + component: { id: { scope: 'my-org.my-scope' } }, + })); + main.fixDirsIfNested(writers); + return writers.map((writer) => writer.writeToPath); + } + + it('should leave both where they were asked to go', () => { + expect(runFixDirs([WORKSPACE_ROOT_DIR, 'packages/comp1'])).to.deep.equal([WORKSPACE_ROOT_DIR, 'packages/comp1']); + }); + + it('should leave the root alone when the workspace already tracks a component inside it', () => { + expect(runFixDirs([WORKSPACE_ROOT_DIR], ['packages/comp1'])).to.deep.equal([WORKSPACE_ROOT_DIR]); + }); + + it('should leave a component alone when the workspace already tracks the root', () => { + expect(runFixDirs(['packages/comp1'], [WORKSPACE_ROOT_DIR])).to.deep.equal(['packages/comp1']); + }); + + it('should still move an ordinary component that another one is written inside of', () => { + // the rule the root is untouched by, so the cases above are not passing on an inert branch + expect(runFixDirs(['bar', 'bar/foo'])).to.deep.equal(['bar_1', 'bar/foo']); + }); +}); + describe('the destination of an already tracked workspace-root component', () => { it('should be the workspace root, whatever path the caller derived without --path', async () => { const writer = Object.create(ComponentWriter.prototype); From f3086271b1a5cfb50c7219dc80d144ea83f82add Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 17:01:00 -0400 Subject: [PATCH 088/102] fix(bit-map): keep .bitmap in the root file-set when the workspace ignores it --- .../legacy/bit-map/component-map.spec.ts | 28 +++++++++++++++++++ components/legacy/bit-map/component-map.ts | 17 +++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/components/legacy/bit-map/component-map.spec.ts b/components/legacy/bit-map/component-map.spec.ts index c5264033018f..283b4a527d5b 100644 --- a/components/legacy/bit-map/component-map.spec.ts +++ b/components/legacy/bit-map/component-map.spec.ts @@ -67,6 +67,34 @@ describe('getFilesByDir', function () { }); }); + describe('a workspace whose own ignore rules hide .bitmap', () => { + // some teams keep .bitmap out of git. the root component versions it all the same - it is the map + // a workspace is restored from, and what "bit clone" reads to know which components to import, so + // dropping it would make a clone come out empty with nothing to say why. + let workspacePath: string; + before(async () => { + workspacePath = await createWorkspace('bit-workspace-ignored-map-', { + '.bitmap': '', + 'README.md': '', + 'workspace.jsonc': '', + '.gitignore': '.bitmap\nbuild/\n', + 'build/out.js': '', + }); + }); + after(() => fs.remove(workspacePath)); + + it('should keep .bitmap in the root file-set anyway', async () => { + const gitIgnore = await getGitIgnoreHarmony(workspacePath); + const files = await getFilesByDir(WORKSPACE_ROOT_DIR, workspacePath, gitIgnore, []); + expect(files.map((file) => file.relativePath)).to.include('.bitmap'); + }); + it('should still honor the rest of the rules, only that one file is spared', async () => { + const gitIgnore = await getGitIgnoreHarmony(workspacePath); + const files = await getFilesByDir(WORKSPACE_ROOT_DIR, workspacePath, gitIgnore, []); + expect(files.map((file) => file.relativePath)).to.not.include('build/out.js'); + }); + }); + describe('scanning the workspace root', () => { let workspacePath: string; let outsidePath: string; diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 67a4c660f3dc..30da85e54002 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -123,11 +123,24 @@ export async function filterByIgnoreFiles( const filteredByRoot: PathLinux[] = gitIgnore.filter(relativePaths); if (dir !== WORKSPACE_ROOT_DIR) return filteredByRoot; const nestedPatterns = await getNestedIgnorePatterns(consumerPath, gitIgnore, relativePaths); - if (!nestedPatterns.length) return filteredByRoot; + if (!nestedPatterns.length) return keepWorkspaceMapFile(filteredByRoot, relativePaths); const filteredByUserRules: PathLinux[] = ignore().add(gitIgnore).add(nestedPatterns).filter(relativePaths); - return ignore() + const filtered = ignore() .add(trackAllFiles ? ALWAYS_IGNORE_LIST : IGNORE_LIST) .filter(filteredByUserRules); + return keepWorkspaceMapFile(filtered, relativePaths); +} + +/** + * `.bitmap` is the workspace-root component's reason to exist: the map it versions is what a workspace + * is restored from, and what `bit clone` reads to know which components to import. so the workspace's + * own ignore rules do not get to drop it - a team that keeps `.bitmap` out of git is exactly the one + * that needs it versioned here, and without it a clone comes out empty with nothing to say why. + */ +function keepWorkspaceMapFile(filtered: PathLinux[], relativePaths: PathLinux[]): PathLinux[] { + if (filtered.some((relativePath) => isWorkspaceMapFile(relativePath))) return filtered; + const mapFile = relativePaths.find((relativePath) => isWorkspaceMapFile(relativePath)); + return mapFile ? [...filtered, mapFile] : filtered; } /** From ca0cd2caae845cc76cbc52278a93d4d084e0a72f Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 17:01:03 -0400 Subject: [PATCH 089/102] fix(bit-map): let the entry that owns a dir win the path index over an inactive one --- components/legacy/bit-map/bit-map.spec.ts | 29 +++++++++++++++++++++++ components/legacy/bit-map/bit-map.ts | 26 ++++++++++++++------ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 24dfa2cff8bc..e987b629b06d 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -165,6 +165,35 @@ describe('BitMap', function () { ); expect(bitMap.getNestedRootDirs(WORKSPACE_ROOT_DIR)).to.deep.equal(['packages/comp1']); }); + it('getComponentIdByPath should give a contested file to the entry that owns the dir now, in both map orders', async () => { + // the root scans the dir of a component that is not on this lane (see getNestedRootDirs), so the + // two entries really do carry the same file. which one answers for it must not come down to the + // order .bitmap happens to list them in. + const rootClaimingTheNestedDir = { + ...rootComponentParams, + files: [ + { name: 'README.md', relativePath: 'README.md', test: false }, + { name: 'index.js', relativePath: 'packages/comp1/index.js', test: false }, + ], + }; + const contested = 'packages/comp1/index.js'; + + const rootFirst = await getBitmapInstance(); + rootFirst.addComponent(rootClaimingTheNestedDir); + rootFirst.addComponent(nestedComponentParams).isAvailableOnCurrentLane = false; + expect(rootFirst.getComponentIdByPath(contested)?.name).to.equal('ws-root'); + + const nestedFirst = await getBitmapInstance(); + nestedFirst.addComponent(nestedComponentParams).isAvailableOnCurrentLane = false; + nestedFirst.addComponent(rootClaimingTheNestedDir); + expect(nestedFirst.getComponentIdByPath(contested)?.name).to.equal('ws-root'); + }); + it('getComponentIdByPath should still answer for a component that owns its dir, the ordinary case', async () => { + const bitMap = await getBitmapInstance(); + bitMap.addComponent(rootComponentParams); + bitMap.addComponent(nestedComponentParams); + expect(bitMap.getComponentIdByPath('packages/comp1/index.js')?.name).to.equal('comp1'); + }); it('getNestedRootDirs should not subtract the dir of a removed component', async () => { const bitMap = await getBitmapInstance(); bitMap.addComponent(rootComponentParams); diff --git a/components/legacy/bit-map/bit-map.ts b/components/legacy/bit-map/bit-map.ts index ea7563581b45..101582268873 100644 --- a/components/legacy/bit-map/bit-map.ts +++ b/components/legacy/bit-map/bit-map.ts @@ -2,7 +2,7 @@ import objectHash from 'object-hash'; import json from 'comment-json'; import fs from 'fs-extra'; import * as path from 'path'; -import { compact, uniq, difference, differenceWith, isEmpty, isString, unionWith, get } from 'lodash'; +import { compact, uniq, difference, differenceWith, isEmpty, isString, partition, unionWith, get } from 'lodash'; import { LaneId } from '@teambit/lane-id'; import { BitError } from '@teambit/bit-error'; import { ComponentID, ComponentIdList } from '@teambit/component-id'; @@ -242,6 +242,15 @@ export class BitMap { delete componentsJson[LANE_KEY]; } + /** + * whether this entry owns its directory at the moment. a component that was removed, or that a lane + * it is unavailable on left behind, stays in `.bitmap` so a switch back can restore it - until then + * it owns nothing, and the workspace root scans its directory in its place. + */ + private ownsItsDirNow(componentMap: ComponentMap): boolean { + return Boolean(componentMap.isAvailableOnCurrentLane) && !componentMap.isRemoved(); + } + /** * the entry of the component that owns the workspace root (rootDir "."), if this workspace has one * right now. a root created on another lane, or removed, stays in `.bitmap` so a switch back can @@ -249,10 +258,7 @@ export class BitMap { */ getWorkspaceRootMap(): ComponentMap | undefined { return this.components.find( - (componentMap) => - componentMap.rootDir === WORKSPACE_ROOT_DIR && - componentMap.isAvailableOnCurrentLane && - !componentMap.isRemoved() + (componentMap) => componentMap.rootDir === WORKSPACE_ROOT_DIR && this.ownsItsDirNow(componentMap) ); } @@ -268,7 +274,7 @@ export class BitMap { this.components // a component removed, or left here by a lane it is unavailable on, does not own its dir in the // meantime - the root scans it, or the files it kept would belong to no component at all. - .filter((componentMap) => componentMap.isAvailableOnCurrentLane && !componentMap.isRemoved()) + .filter((componentMap) => this.ownsItsDirNow(componentMap)) .map((componentMap) => componentMap.rootDir) .filter((nested): nested is PathLinuxRelative => Boolean(nested) && nested !== WORKSPACE_ROOT_DIR) // without the trailing slash a .bitmap edited by hand may carry (see isSameDir). callers append @@ -964,7 +970,13 @@ export class BitMap { _populateAllPaths() { if (isEmpty(this.paths)) { - this.components.forEach((component) => { + // the entries that do not own their directory right now are indexed first, so that an active + // component writing the same path overwrites them rather than the other way round. the workspace + // root scans the directory of an inactive component (see getNestedRootDirs), so the two really do + // claim the same files, and without this the winner would be whichever `.bitmap` happened to list + // last. they are still indexed, so a workspace with no root does not lose their paths entirely. + const [active, inactive] = partition(this.components, (component) => this.ownsItsDirNow(component)); + [...inactive, ...active].forEach((component) => { component.files.forEach((file) => { const relativeToConsumer = component.rootDir ? pathJoinLinux(component.rootDir, file.relativePath) From 2ae6395c1a652a17eecfd9a67a323c796b1d34ad Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 17:01:05 -0400 Subject: [PATCH 090/102] fix(workspace-root): clear a root a component was snapped in when this workspace has none --- scopes/component/snapping/version-maker.ts | 20 +++++++++++----- scopes/workspace/workspace-root/index.ts | 1 + .../workspace-root-data.spec.ts | 24 +++++++++++++++++++ .../workspace-root/workspace-root-data.ts | 16 +++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index fdf87040bd48..a56e9b6e9c4d 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -33,7 +33,7 @@ import { DependencyResolverAspect, COMPONENT_DEP_TYPE } from '@teambit/dependenc import type { Registries } from '@teambit/pkg.entities.registry'; import type { ScopeMain, StagedConfig } from '@teambit/scope'; import type { Workspace, AutoTagResult } from '@teambit/workspace'; -import { findWorkspaceRootMap, writeWorkspaceRoot } from '@teambit/workspace-root'; +import { clearWorkspaceRoot, findWorkspaceRootMap, writeWorkspaceRoot } from '@teambit/workspace-root'; import { pMapPool } from '@teambit/toolbox.promise.map-pool'; import type { PackageIntegritiesByPublishedPackages, SnappingMain, TagDataPerComp } from './snapping.main.runtime'; import type { LaneId } from '@teambit/lane-id'; @@ -776,21 +776,29 @@ export class VersionMaker { * so the version is only missing on the paths that don't, such as a merge snap made while the root * was never snapped. the root itself records nothing here, it carries the isRoot marker instead. * see WorkspaceRootMain. + * + * a workspace with no root of its own clears the field instead of leaving it: the data travels with + * the component, so a component imported from a workspace that had one would otherwise keep naming + * it in every version snapped here. */ private recordWorkspaceRoot() { const consumer = this.consumer; if (!consumer) return; const rootMap = findWorkspaceRootMap(consumer.bitMap); - if (!rootMap) return; - const rootInBatch = this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); - const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap.id; + const rootInBatch = + rootMap && this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); + const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap?.id; this.allComponentsToTag.forEach((component) => { - if (component.id.isEqualWithoutVersion(rootMap.id)) return; + if (rootMap && component.id.isEqualWithoutVersion(rootMap.id)) return; // hidden lane entries (lane.updateDependents) cascade into the batch from the scope rather than // from the workspace, so they were not snapped in this root. absence from .bitmap is how they // are told apart elsewhere in this file as well if (!consumer.bitMap.getComponentIfExist(component.id, { ignoreVersion: true })) return; - writeWorkspaceRoot(component.extensions, rootId); + // with no root in this workspace there is nothing to record, and what a previous one recorded has + // to go: the data travels with the component, so keeping it would have the new version claim a + // workspace root it was not made in + if (rootId) writeWorkspaceRoot(component.extensions, rootId); + else clearWorkspaceRoot(component.extensions); }); } diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts index 8383836627cd..e27a9f896bf0 100644 --- a/scopes/workspace/workspace-root/index.ts +++ b/scopes/workspace/workspace-root/index.ts @@ -3,6 +3,7 @@ export type { WorkspaceRootMain } from './workspace-root.main.runtime'; export type { WorkspaceRootData } from './workspace-root-data'; export type { CloneOptions, CloneResult } from './clone'; export { + clearWorkspaceRoot, findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot, diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts index d0107aafbc3d..2de1e3833db5 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.spec.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -4,6 +4,7 @@ import { BitMap } from '@teambit/legacy.bit-map'; import { Extensions } from '@teambit/legacy.constants'; import { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; import { + clearWorkspaceRoot, findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot, @@ -81,4 +82,27 @@ describe('workspace-root data', () => { expect(readWorkspaceRoot(ExtensionDataList.fromArray([]))).to.be.undefined; }); }); + + describe('clearWorkspaceRoot', () => { + it('should drop the root a previous workspace recorded', () => { + // the data travels with the component, so a snap in a workspace that has no root of its own + // would otherwise keep naming the one it came from + const extensions = ExtensionDataList.fromArray([]); + writeWorkspaceRoot(extensions, rootId); + clearWorkspaceRoot(extensions); + expect(readWorkspaceRoot(extensions)).to.be.undefined; + }); + it('should drop a stale isRoot from a component that is no longer the root', () => { + const extensions = ExtensionDataList.fromArray([ + new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, { isRoot: true }), + ]); + clearWorkspaceRoot(extensions); + expect(isWorkspaceRootComponent(extensions)).to.be.false; + }); + it('should do nothing to a component that carries no data of this aspect', () => { + const extensions = ExtensionDataList.fromArray([]); + clearWorkspaceRoot(extensions); + expect(extensions).to.have.lengthOf(0); + }); + }); }); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts index 1663290267fd..0661eac74567 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -50,6 +50,22 @@ export function readWorkspaceRoot(extensions: ExtensionDataList): ComponentID | return root ? ComponentID.fromString(root) : undefined; } +/** + * drop what another workspace recorded on this component. + * + * the data travels with the component, so one imported into a workspace that has no root of its own + * still carries the root it was snapped in, and snapping it here would carry it into the new version - + * claiming it was made in a workspace it has never been in. the same call clears a stale `isRoot` from + * a component that was the root once and is tracked in a directory of its own now; left there, an + * import onto "." or a clone would take ordinary source for a workspace root. + */ +export function clearWorkspaceRoot(extensions: ExtensionDataList): void { + const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); + if (!existing?.data) return; + delete existing.data.root; + delete existing.data.isRoot; +} + export function writeWorkspaceRoot(extensions: ExtensionDataList, rootId: ComponentID): void { const data: WorkspaceRootData = { root: rootId.toString() }; const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); From 4c7db9d98922ddff750882c1594320d07e97fda7 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 17:01:09 -0400 Subject: [PATCH 091/102] fix(workspace-root): reject a non-string rootDir with the clone error, not a type error --- e2e/harmony/add-harmony.e2e.ts | 11 ------ .../component-writer/component-writer.spec.ts | 38 +++++++++++++++++++ scopes/workspace/workspace-root/clone.spec.ts | 5 +++ scopes/workspace/workspace-root/clone.ts | 6 ++- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 30c316819f98..cf52519e82eb 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -319,17 +319,6 @@ describe('add command on Harmony', function () { fs.removeSync(outside); }); }); - describe('importing an ordinary component onto the root', () => { - before(() => { - helper.scopeHelper.reInitWorkspace(); - helper.scopeHelper.addRemoteScope(); - }); - it('should refuse, only a workspace-root component may own "."', () => { - // it would own every unclaimed file from the next scan on, and drop out of install and link - const cmd = () => helper.command.importComponentWithoutInstall('comp1', '--path .'); - expect(cmd).to.throw('not a workspace-root component'); - }); - }); describe('importing it onto the root of a workspace that already tracks components', () => { before(() => { helper.scopeHelper.reInitWorkspace(); diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index dc5bfa01eed2..3d68ee394441 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -3,6 +3,8 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; +import { WorkspaceRootAspect } from '@teambit/workspace-root'; import ComponentWriter, { isOwnedByNestedComponent } from './component-writer'; import { ComponentWriterMain } from './component-writer.main.runtime'; @@ -168,6 +170,42 @@ describe('the workspace-root import preflight checks', () => { }); }); +describe('the guard on what may be written to the workspace root', () => { + /** + * it runs in the same branch as the symlink preflight, which an e2e case covers on the real + * "--path ." path - so what is left to check is the rule itself, on the marker a version carries. + */ + const componentWith = (extensions: ExtensionDataList) => + ({ id: { toString: () => 'my-scope/comp1' }, extensions }) as any; + + it('should refuse an ordinary component, only a workspace-root component may own "."', () => { + const main = Object.create(ComponentWriterMain.prototype); + expect(() => main.throwForNonWorkspaceRootComponent(componentWith(ExtensionDataList.fromArray([])))).to.throw( + 'not a workspace-root component' + ); + }); + + it('should accept a component whose version carries the root marker', () => { + const extensions = ExtensionDataList.fromArray([ + new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, { isRoot: true }), + ]); + const main = Object.create(ComponentWriterMain.prototype); + expect(() => main.throwForNonWorkspaceRootComponent(componentWith(extensions))).to.not.throw(); + }); + + it('should refuse a member, which points at its root rather than being one', () => { + const extensions = ExtensionDataList.fromArray([ + new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, { + root: 'my-scope/ws-root@0.0.1', + }), + ]); + const main = Object.create(ComponentWriterMain.prototype); + expect(() => main.throwForNonWorkspaceRootComponent(componentWith(extensions))).to.throw( + 'not a workspace-root component' + ); + }); +}); + describe('writing the workspace-root component in the same batch as a component nested in it', () => { /** * the batch goes through fixDirsIfNested, which moves a component aside when another one is to be diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index d49d2785feb2..0e39a90ed773 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -53,6 +53,11 @@ describe('resolveComponentDir', () => { const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir: '.' }); expect(resolve).to.throw('not a directory inside the workspace'); }); + it('should refuse a root-dir that is not a string, the map came from a remote', () => { + // the parser asserts the entry's shape, not its values, so this reaches here as it was written + const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir: 42 as unknown as string }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); it('should refuse an entry without a root-dir rather than fail on it later', () => { const resolve = () => resolveComponentDir(workspacePath, { id: 'a' }); expect(resolve).to.throw('not a directory inside the workspace'); diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 48002cffcacc..7ddbf4ca36a9 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -290,7 +290,11 @@ export function resolveComponentDir(workspacePath: string, entry: VersionedBitma // the absolute check goes before the resolve: an absolute path that happens to sit under this // workspace resolves to an ordinary relative one and would pass every check below, though it is // still a directory of the machine the root was snapped on rather than one of this workspace. - const target = rootDir && !path.isAbsolute(rootDir) ? path.resolve(workspacePath, rootDir) : undefined; + // the type is checked, not assumed: the entry is parsed from a `.bitmap` that came from a remote, and + // the parser asserts its shape without looking at the values. handing a number or an object to + // path.isAbsolute would abort the clone with a node type error instead of the message below. + const isUsable = typeof rootDir === 'string' && rootDir.length > 0 && !path.isAbsolute(rootDir); + const target = isUsable ? path.resolve(workspacePath, rootDir) : undefined; const relative = target ? path.relative(workspacePath, target) : undefined; // a leading ".." is only a way out when it is the whole segment: a directory may be named "..cache" const climbsOut = relative === '..' || relative?.startsWith(`..${path.sep}`); From 783f894c26b7b1a3611d27709437425be7cd24c2 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 17 Sep 2026 17:27:23 -0400 Subject: [PATCH 092/102] fix(workspace-root): clear the former root pointer when a member becomes the workspace root --- scopes/component/snapping/version-maker.ts | 15 +++++++++++-- scopes/workspace/workspace-root/index.ts | 1 + .../workspace-root-data.spec.ts | 22 +++++++++++++++++++ .../workspace-root/workspace-root-data.ts | 16 +++++++++++++- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index a56e9b6e9c4d..719531f71e48 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -33,7 +33,12 @@ import { DependencyResolverAspect, COMPONENT_DEP_TYPE } from '@teambit/dependenc import type { Registries } from '@teambit/pkg.entities.registry'; import type { ScopeMain, StagedConfig } from '@teambit/scope'; import type { Workspace, AutoTagResult } from '@teambit/workspace'; -import { clearWorkspaceRoot, findWorkspaceRootMap, writeWorkspaceRoot } from '@teambit/workspace-root'; +import { + clearWorkspaceRoot, + clearWorkspaceRootPointer, + findWorkspaceRootMap, + writeWorkspaceRoot, +} from '@teambit/workspace-root'; import { pMapPool } from '@teambit/toolbox.promise.map-pool'; import type { PackageIntegritiesByPublishedPackages, SnappingMain, TagDataPerComp } from './snapping.main.runtime'; import type { LaneId } from '@teambit/lane-id'; @@ -789,7 +794,13 @@ export class VersionMaker { rootMap && this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap?.id; this.allComponentsToTag.forEach((component) => { - if (rootMap && component.id.isEqualWithoutVersion(rootMap.id)) return; + if (rootMap && component.id.isEqualWithoutVersion(rootMap.id)) { + // the root records no root of its own - but it may have been a member before it was tracked at + // ".", and the loader merges the isRoot marker into what it already carried instead of replacing + // it. left alone, this version would claim to be a workspace root and a member of another one. + clearWorkspaceRootPointer(component.extensions); + return; + } // hidden lane entries (lane.updateDependents) cascade into the batch from the scope rather than // from the workspace, so they were not snapped in this root. absence from .bitmap is how they // are told apart elsewhere in this file as well diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts index e27a9f896bf0..73aee4999951 100644 --- a/scopes/workspace/workspace-root/index.ts +++ b/scopes/workspace/workspace-root/index.ts @@ -4,6 +4,7 @@ export type { WorkspaceRootData } from './workspace-root-data'; export type { CloneOptions, CloneResult } from './clone'; export { clearWorkspaceRoot, + clearWorkspaceRootPointer, findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot, diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts index 2de1e3833db5..a85d54c2261c 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.spec.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -5,6 +5,7 @@ import { Extensions } from '@teambit/legacy.constants'; import { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; import { clearWorkspaceRoot, + clearWorkspaceRootPointer, findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot, @@ -105,4 +106,25 @@ describe('workspace-root data', () => { expect(extensions).to.have.lengthOf(0); }); }); + + describe('clearWorkspaceRootPointer', () => { + it('should drop the root a component was a member of, and keep it marked as a root itself', () => { + // a member promoted to the workspace root: the loader merges the marker into what it carried, so + // without this the version says it is a root and a member of another one at once + const extensions = ExtensionDataList.fromArray([ + new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, { + root: 'my-scope/former-root@0.0.1', + isRoot: true, + }), + ]); + clearWorkspaceRootPointer(extensions); + expect(readWorkspaceRoot(extensions)).to.be.undefined; + expect(isWorkspaceRootComponent(extensions)).to.be.true; + }); + it('should do nothing to a component that carries no data of this aspect', () => { + const extensions = ExtensionDataList.fromArray([]); + clearWorkspaceRootPointer(extensions); + expect(extensions).to.have.lengthOf(0); + }); + }); }); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts index 0661eac74567..57d4791cddc8 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -60,10 +60,24 @@ export function readWorkspaceRoot(extensions: ExtensionDataList): ComponentID | * import onto "." or a clone would take ordinary source for a workspace root. */ export function clearWorkspaceRoot(extensions: ExtensionDataList): void { + clearWorkspaceRootPointer(extensions); + const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); + if (existing?.data) delete existing.data.isRoot; +} + +/** + * drop the pointer to the root this component was snapped in, and keep the marker saying it is a root + * itself. + * + * for a component that was a member before it was tracked at ".": the loader merges the marker into + * the data the component already carried rather than replacing it, so without this the version would + * say the component is a workspace root and a member of a different one at the same time, and reading + * its root back would name that other component. + */ +export function clearWorkspaceRootPointer(extensions: ExtensionDataList): void { const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); if (!existing?.data) return; delete existing.data.root; - delete existing.data.isRoot; } export function writeWorkspaceRoot(extensions: ExtensionDataList, rootId: ComponentID): void { From 6ff664e3840f1b5c181b977eb85780685b14b2f9 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 11:02:16 -0400 Subject: [PATCH 093/102] refactor(component-writer): resolve the per-component write path inside the dir-conflict check it was threaded in from one call site, so relocateOccupiedDirs - which cannot see it - fell back to skipping the check whenever the component was tracked. Co-Authored-By: Claude Opus 5 (1M context) --- .../component-writer.main.runtime.ts | 15 ++++---- .../component-writer/component-writer.spec.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index b2c735794f57..9c5f6de77a39 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -299,7 +299,7 @@ export class ComponentWriterMain { // fixDirs* passes (which may still adjust writeToPath); otherwise fail here when the target dir is occupied. // the workspace root is never relocated (see relocateOccupiedDirs), so its own check runs either way. if (this.consumer && (!opts.writeToEmptyDir || componentRootDir === WORKSPACE_ROOT_DIR)) { - this.throwErrorWhenDirectoryNotEmpty(component, componentRootDir, existingComponentMap, opts, writeToPath); + this.throwErrorWhenDirectoryNotEmpty(component, componentRootDir, existingComponentMap, opts); } return { workspace: this.workspace, @@ -354,15 +354,15 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace * target directory already belongs to this exact component (so overriding it in place is safe). */ private shouldSkipDirConflictCheck( + component: ConsumerComponent, componentDirRelative: PathLinuxRelative, componentMap: ComponentMap | null | undefined, - opts: ManyComponentsWriterParams, - writeToPath?: string + opts: ManyComponentsWriterParams ): boolean { if (opts.skipWritingToFs) return true; if (!componentMap) return false; // no writeToPath: it goes to the default directory. an existing componentMap means the component is not new. - if (!writeToPath) return true; + if (!this.getWriteToPath(component, opts)) return true; // writeToPath specified and that directory is already used for that component. compare against the // normalized componentDirRelative (not the raw opts.writeToPath, which may be absolute/OS-specific). return componentMap.rootDir === componentDirRelative; @@ -471,10 +471,9 @@ use --override to overwrite them` component: ConsumerComponent, componentDirRelative: PathLinuxRelative, componentMap: ComponentMap | null | undefined, - opts: ManyComponentsWriterParams, - writeToPath?: string + opts: ManyComponentsWriterParams ) { - if (this.shouldSkipDirConflictCheck(componentDirRelative, componentMap, opts, writeToPath)) return; + if (this.shouldSkipDirConflictCheck(component, componentDirRelative, componentMap, opts)) return; const componentDir = this.consumer.toAbsolutePath(componentDirRelative); if (!fs.pathExistsSync(componentDir)) return; @@ -523,7 +522,7 @@ either use --path to specify a different directory or modify "defaultDirectory" // the workspace root is a target only for a workspace-root component, and "._1" is not the // workspace root. its conflicts are handled by throwForOccupiedWorkspaceRoot. if (currentDir === WORKSPACE_ROOT_DIR) return; - if (this.shouldSkipDirConflictCheck(currentDir, componentMap, opts)) return; + if (this.shouldSkipDirConflictCheck(componentWriter.component, currentDir, componentMap, opts)) return; const unavailableReason = this.getDirUnavailableReason(currentDir, componentMap); if (!unavailableReason) return; diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 3d68ee394441..297ff19a9c0b 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -241,6 +241,44 @@ describe('writing the workspace-root component in the same batch as a component }); }); +describe('deciding whether the directory-conflict check applies to a component', () => { + /** + * the decision turns on whether *this* component was asked to go somewhere, which is not the same + * question as the directory it ends up at: without --path that directory is the default one, and + * with --path-per-id the batch-level path answers for the wrong component. so it is resolved the + * same way the write itself resolves it. + */ + function shouldSkip(opts: Record, rootDir?: string, dir = 'some-dir') { + const main = Object.create(ComponentWriterMain.prototype); + const component = { id: { toStringWithoutVersion: () => 'my-scope/comp1' } }; + return main.shouldSkipDirConflictCheck(component, dir, rootDir === undefined ? undefined : { rootDir }, opts); + } + + it('should check a component asked for a directory it does not already own', () => { + expect(shouldSkip({ writeToPath: 'some-dir' }, 'other-dir')).to.be.false; + }); + + it('should skip a component asked for the directory it holds today, it overrides itself in place', () => { + expect(shouldSkip({ writeToPath: 'some-dir' }, 'some-dir')).to.be.true; + }); + + it('should skip a tracked component that was asked for nothing, it goes to its default directory', () => { + expect(shouldSkip({}, 'other-dir')).to.be.true; + }); + + it('should take the path asked for this component, not the one asked for the batch', () => { + expect(shouldSkip({ writeToPathPerId: { 'my-scope/comp1': 'some-dir' } }, 'other-dir')).to.be.false; + }); + + it('should check a component that is not tracked yet, whatever was asked for it', () => { + expect(shouldSkip({})).to.be.false; + }); + + it('should skip everything when nothing is written to the filesystem at all', () => { + expect(shouldSkip({ skipWritingToFs: true, writeToPath: 'some-dir' }, 'other-dir')).to.be.true; + }); +}); + describe('the destination of an already tracked workspace-root component', () => { it('should be the workspace root, whatever path the caller derived without --path', async () => { const writer = Object.create(ComponentWriter.prototype); From eb1e0956698db46281ebb0c2064e82fda0e498df Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 12:48:12 -0400 Subject: [PATCH 094/102] test(workspace-root): cover a member imported alone into a workspace with no root Co-Authored-By: Claude Opus 5 (1M context) --- e2e/harmony/add-harmony.e2e.ts | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index cf52519e82eb..c96b7808c551 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -339,6 +339,48 @@ describe('add command on Harmony', function () { }); }); }); + describe('a member imported on its own into a workspace that has no root', () => { + let rootIdWithVersion: string; + let bitMapAfterImport: Record; + const rootDataOf = (id: string) => + helper.command.catComponent(id).extensions.find((ext) => ext.name === 'teambit.workspace/workspace-root')?.data; + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.tagAllWithoutBuild('--ignore-issues "*"'); + helper.command.export(); + rootIdWithVersion = `${helper.scopes.remote}/ws-root@0.0.1`; + + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.command.importComponentWithoutInstall('comp1'); + bitMapAfterImport = helper.bitMap.read(); + }); + it('should carry the root it was tagged in as aspect data on the version it imported', () => { + expect(rootDataOf('comp1@0.0.1')).to.deep.equal({ root: rootIdWithVersion }); + }); + it('should not bring the root component along, the pointer is provenance and not a dependency', () => { + expect(bitMapAfterImport).to.not.have.property('ws-root'); + expect(helper.command.listParsed().map((comp) => comp.id)).to.deep.equal([`${helper.scopes.remote}/comp1`]); + }); + describe('tagging it here, where there is no root to record', () => { + before(() => { + helper.command.tagAllWithoutBuild('--unmodified'); + }); + it('should drop the root of the workspace it came from, not carry it into the new version', () => { + // a version naming a root it was never made in would send a consumer after the wrong root + // files. aspect data is recomputed per tag rather than inherited from the model, so the + // pointer does not survive - the entry itself does, carried by its (empty) config. + expect(rootDataOf('comp1@0.0.2')?.root).to.be.undefined; + }); + it('should leave the version it was imported at untouched', () => { + expect(rootDataOf('comp1@0.0.1')).to.deep.equal({ root: rootIdWithVersion }); + }); + }); + }); describe('env of the workspace-root component', () => { // one status per workspace state, the assertions read from it let status: Record; From 8b5969495f6d81230c3fa63e2a7acf5139771df0 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 13:13:31 -0400 Subject: [PATCH 095/102] refactor(workspace-root): drop the clear helpers, aspect data is not inherited the data is supplied fresh by the loader on each load and replaced wholesale by writeWorkspaceRoot, so neither clear had anything to undo. verified by stubbing both to no-ops: the add-harmony e2e file passed all 64 cases unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- scopes/component/snapping/version-maker.ts | 35 +++++--------- scopes/workspace/workspace-root/index.ts | 2 - .../workspace-root-data.spec.ts | 46 ++----------------- .../workspace-root/workspace-root-data.ts | 33 ++----------- 4 files changed, 21 insertions(+), 95 deletions(-) diff --git a/scopes/component/snapping/version-maker.ts b/scopes/component/snapping/version-maker.ts index 719531f71e48..52e56c14713f 100644 --- a/scopes/component/snapping/version-maker.ts +++ b/scopes/component/snapping/version-maker.ts @@ -33,12 +33,7 @@ import { DependencyResolverAspect, COMPONENT_DEP_TYPE } from '@teambit/dependenc import type { Registries } from '@teambit/pkg.entities.registry'; import type { ScopeMain, StagedConfig } from '@teambit/scope'; import type { Workspace, AutoTagResult } from '@teambit/workspace'; -import { - clearWorkspaceRoot, - clearWorkspaceRootPointer, - findWorkspaceRootMap, - writeWorkspaceRoot, -} from '@teambit/workspace-root'; +import { findWorkspaceRootMap, writeWorkspaceRoot } from '@teambit/workspace-root'; import { pMapPool } from '@teambit/toolbox.promise.map-pool'; import type { PackageIntegritiesByPublishedPackages, SnappingMain, TagDataPerComp } from './snapping.main.runtime'; import type { LaneId } from '@teambit/lane-id'; @@ -782,34 +777,26 @@ export class VersionMaker { * was never snapped. the root itself records nothing here, it carries the isRoot marker instead. * see WorkspaceRootMain. * - * a workspace with no root of its own clears the field instead of leaving it: the data travels with - * the component, so a component imported from a workspace that had one would otherwise keep naming - * it in every version snapped here. + * a workspace with no root of its own writes nothing, and has nothing to undo either: aspect data is + * supplied fresh by the loader on every load rather than inherited from the version before it, so a + * component imported from a workspace that had a root arrives here without one. what does carry over + * is the extension's (empty) config, which is why the entry itself outlives the data it held. */ private recordWorkspaceRoot() { const consumer = this.consumer; if (!consumer) return; const rootMap = findWorkspaceRootMap(consumer.bitMap); - const rootInBatch = - rootMap && this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); - const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap?.id; + if (!rootMap) return; + const rootInBatch = this.allComponentsToTag.find((component) => component.id.isEqualWithoutVersion(rootMap.id)); + const rootId = rootInBatch ? rootInBatch.id.changeVersion(rootInBatch.version) : rootMap.id; this.allComponentsToTag.forEach((component) => { - if (rootMap && component.id.isEqualWithoutVersion(rootMap.id)) { - // the root records no root of its own - but it may have been a member before it was tracked at - // ".", and the loader merges the isRoot marker into what it already carried instead of replacing - // it. left alone, this version would claim to be a workspace root and a member of another one. - clearWorkspaceRootPointer(component.extensions); - return; - } + // the root records no root of its own, it carries the isRoot marker the loader gives it + if (component.id.isEqualWithoutVersion(rootMap.id)) return; // hidden lane entries (lane.updateDependents) cascade into the batch from the scope rather than // from the workspace, so they were not snapped in this root. absence from .bitmap is how they // are told apart elsewhere in this file as well if (!consumer.bitMap.getComponentIfExist(component.id, { ignoreVersion: true })) return; - // with no root in this workspace there is nothing to record, and what a previous one recorded has - // to go: the data travels with the component, so keeping it would have the new version claim a - // workspace root it was not made in - if (rootId) writeWorkspaceRoot(component.extensions, rootId); - else clearWorkspaceRoot(component.extensions); + writeWorkspaceRoot(component.extensions, rootId); }); } diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts index 73aee4999951..8383836627cd 100644 --- a/scopes/workspace/workspace-root/index.ts +++ b/scopes/workspace/workspace-root/index.ts @@ -3,8 +3,6 @@ export type { WorkspaceRootMain } from './workspace-root.main.runtime'; export type { WorkspaceRootData } from './workspace-root-data'; export type { CloneOptions, CloneResult } from './clone'; export { - clearWorkspaceRoot, - clearWorkspaceRootPointer, findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot, diff --git a/scopes/workspace/workspace-root/workspace-root-data.spec.ts b/scopes/workspace/workspace-root/workspace-root-data.spec.ts index a85d54c2261c..9790ee86d80f 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.spec.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -4,8 +4,6 @@ import { BitMap } from '@teambit/legacy.bit-map'; import { Extensions } from '@teambit/legacy.constants'; import { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; import { - clearWorkspaceRoot, - clearWorkspaceRootPointer, findWorkspaceRootMap, isWorkspaceRootComponent, readWorkspaceRoot, @@ -82,49 +80,15 @@ describe('workspace-root data', () => { it('should return undefined for a component with no pointer', () => { expect(readWorkspaceRoot(ExtensionDataList.fromArray([]))).to.be.undefined; }); - }); - - describe('clearWorkspaceRoot', () => { - it('should drop the root a previous workspace recorded', () => { - // the data travels with the component, so a snap in a workspace that has no root of its own - // would otherwise keep naming the one it came from - const extensions = ExtensionDataList.fromArray([]); - writeWorkspaceRoot(extensions, rootId); - clearWorkspaceRoot(extensions); - expect(readWorkspaceRoot(extensions)).to.be.undefined; - }); - it('should drop a stale isRoot from a component that is no longer the root', () => { + it('should replace the data rather than merge into it, so a former root is not both', () => { + // a root moved out of "." into a directory of its own becomes an ordinary member. merging would + // leave the isRoot marker behind and the version would claim both roles at once const extensions = ExtensionDataList.fromArray([ new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, { isRoot: true }), ]); - clearWorkspaceRoot(extensions); + writeWorkspaceRoot(extensions, rootId); expect(isWorkspaceRootComponent(extensions)).to.be.false; - }); - it('should do nothing to a component that carries no data of this aspect', () => { - const extensions = ExtensionDataList.fromArray([]); - clearWorkspaceRoot(extensions); - expect(extensions).to.have.lengthOf(0); - }); - }); - - describe('clearWorkspaceRootPointer', () => { - it('should drop the root a component was a member of, and keep it marked as a root itself', () => { - // a member promoted to the workspace root: the loader merges the marker into what it carried, so - // without this the version says it is a root and a member of another one at once - const extensions = ExtensionDataList.fromArray([ - new ExtensionDataEntry(undefined, undefined, WorkspaceRootAspect.id, undefined, { - root: 'my-scope/former-root@0.0.1', - isRoot: true, - }), - ]); - clearWorkspaceRootPointer(extensions); - expect(readWorkspaceRoot(extensions)).to.be.undefined; - expect(isWorkspaceRootComponent(extensions)).to.be.true; - }); - it('should do nothing to a component that carries no data of this aspect', () => { - const extensions = ExtensionDataList.fromArray([]); - clearWorkspaceRootPointer(extensions); - expect(extensions).to.have.lengthOf(0); + expect(readWorkspaceRoot(extensions)?.toString()).to.equal(rootId.toString()); }); }); }); diff --git a/scopes/workspace/workspace-root/workspace-root-data.ts b/scopes/workspace/workspace-root/workspace-root-data.ts index 57d4791cddc8..3dad937500dd 100644 --- a/scopes/workspace/workspace-root/workspace-root-data.ts +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -51,35 +51,12 @@ export function readWorkspaceRoot(extensions: ExtensionDataList): ComponentID | } /** - * drop what another workspace recorded on this component. - * - * the data travels with the component, so one imported into a workspace that has no root of its own - * still carries the root it was snapped in, and snapping it here would carry it into the new version - - * claiming it was made in a workspace it has never been in. the same call clears a stale `isRoot` from - * a component that was the root once and is tracked in a directory of its own now; left there, an - * import onto "." or a clone would take ordinary source for a workspace root. - */ -export function clearWorkspaceRoot(extensions: ExtensionDataList): void { - clearWorkspaceRootPointer(extensions); - const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); - if (existing?.data) delete existing.data.isRoot; -} - -/** - * drop the pointer to the root this component was snapped in, and keep the marker saying it is a root - * itself. - * - * for a component that was a member before it was tracked at ".": the loader merges the marker into - * the data the component already carried rather than replacing it, so without this the version would - * say the component is a workspace root and a member of a different one at the same time, and reading - * its root back would name that other component. + * nothing clears this data: it is not inherited from the version before it. the loader supplies the + * aspect's data on every load, so a component arrives with the root of the workspace it is in now, or + * with none - what an earlier workspace recorded never reaches the next version. the data is replaced + * wholesale rather than merged, so a component that changes role (a member tracked at "." later, or a + * root moved into a directory of its own) does not keep the marker of the role it left. */ -export function clearWorkspaceRootPointer(extensions: ExtensionDataList): void { - const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); - if (!existing?.data) return; - delete existing.data.root; -} - export function writeWorkspaceRoot(extensions: ExtensionDataList, rootId: ComponentID): void { const data: WorkspaceRootData = { root: rootId.toString() }; const existing = extensions.findCoreExtension(WorkspaceRootAspect.id); From a34cb87bd654bc458bbfc039f29ba18bf690de07 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 13:26:46 -0400 Subject: [PATCH 096/102] feat(tracker): require --root to track the workspace root with bit add "bit add ." is one keystroke from "git add ." and meant something else until now, so the intent is spelled out. a workspace that already has a root does not ask again - the existing "already tracked by" message is the useful one there. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/harmony/add-harmony.e2e.ts | 38 +++++++++++++---- scopes/component/tracker/add-cmd.ts | 5 ++- scopes/component/tracker/add-components.ts | 38 +++++++++++++++++ .../tracker/track-workspace-root.spec.ts | 42 +++++++++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index c96b7808c551..da2775fa8469 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -39,13 +39,35 @@ describe('add command on Harmony', function () { helper.general.expectToThrow(cmd, error); }); }); + describe('tracking the workspace root without the --root flag', () => { + let output: string; + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fs.outputFile('README.md', '# workspace root\n'); + output = helper.general.runWithTryCatch('bit add .'); + }); + it('should refuse, and name the command that does it', () => { + // "bit add ." is one keystroke from "git add .", so the intent is spelled out rather than assumed + expect(output).to.have.string('bit add . --root'); + }); + it('should leave the workspace root untracked', () => { + const entries: any[] = Object.values(helper.bitMap.read()); + expect(entries.some((entry) => entry?.rootDir === '.')).to.be.false; + }); + it('should refuse the flag when the path is not the workspace root, rather than ignore it', () => { + helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); + expect(helper.general.runWithTryCatch('bit add comp1 --root')).to.have.string( + 'none of the given paths is the workspace root' + ); + }); + }); describe('adding the workspace root as a component', () => { let rootFiles: string[]; before(() => { helper.scopeHelper.reInitWorkspace(); helper.fixtures.populateComponents(1); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); // written after tracking. the root file-set is re-scanned, not frozen at add-time. helper.fs.outputFile('LICENSE', 'MIT\n'); rootFiles = helper.command.getComponentFiles('ws-root'); @@ -83,7 +105,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); }); it('should not be modified right after snapping, despite tracking .bitmap', () => { @@ -167,7 +189,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); helper.fs.outputFile('untracked-by-bit.txt', 'not a component file\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); // a dependency that happens to share the package name the root's id derives const packageName = helper.general.getPackageNameByCompName('ws-root', false); helper.fs.outputFile(path.join('node_modules', packageName, 'index.js'), ''); @@ -205,7 +227,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); helper.fs.outputFile('docs/guide.md', '# guide\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); firstSnap = helper.command.getHead('ws-root'); helper.fs.outputFile('README.md', '# workspace root v2\n'); @@ -349,7 +371,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); helper.command.tagAllWithoutBuild('--ignore-issues "*"'); helper.command.export(); rootIdWithVersion = `${helper.scopes.remote}/ws-root@0.0.1`; @@ -393,7 +415,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); status = helper.command.statusJson(); }); it('should be tracked with the empty env, not the regular default env', () => { @@ -464,7 +486,7 @@ describe('add command on Harmony', function () { helper.command.addComponent('comp1', { i: 'comp1' }); helper.fs.outputFile('package.json', '{ "name": "monorepo", "private": true }\n'); helper.fs.outputFile('README.md', '# workspace root\n'); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); }); describe('cloning the workspace from its root component', () => { // bit clone runs outside a workspace and needs no bit init. the remote is registered globally, as @@ -539,7 +561,7 @@ describe('add command on Harmony', function () { helper.fs.outputFile('README.md', '# on main\n'); helper.command.addComponent('comp1', { i: 'comp1' }); helper.command.addComponent('comp2', { i: 'comp2' }); - helper.command.addComponent('.', { i: 'ws-root' }); + helper.command.addComponent('.', '-i ws-root --root'); helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); helper.command.export(); comp1MainHead = helper.command.getHead('comp1'); diff --git a/scopes/component/tracker/add-cmd.ts b/scopes/component/tracker/add-cmd.ts index 1ec5653e71dc..89b8c070e80a 100644 --- a/scopes/component/tracker/add-cmd.ts +++ b/scopes/component/tracker/add-cmd.ts @@ -15,6 +15,7 @@ type AddFlags = { scope?: string; env?: string; override: boolean; + root?: boolean; }; type AddResults = { @@ -41,6 +42,7 @@ export class AddCmd implements Command { `sets the component's scope. if not entered, the default-scope from workspace.jsonc will be used`, ], ['e', 'env ', "set the component's environment. (overrides the env from variants if exists)"], + ['', 'root', 'track the workspace root itself as a component, which owns every file no other component claims'], ['j', 'json', 'output as json format'], ] as CommandOptions; loader = true; @@ -90,7 +92,7 @@ export class AddCmd implements Command { async json( [paths = []]: [string[]], - { id, main, namespace, scope, env, override = false }: AddFlags + { id, main, namespace, scope, env, override = false, root = false }: AddFlags ): Promise { if (namespace && id) { throw new BitError( @@ -109,6 +111,7 @@ export class AddCmd implements Command { defaultScope: scope, override, env, + root, }); return { addedComponents: addedComponents.map((added) => ({ diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index 362d941d3fd9..a5e1f49eca8a 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -84,6 +84,11 @@ export type AddProps = { config?: Config; shouldHandleOutOfSync?: boolean; env?: string; + /** + * spells out the intent to track the workspace root itself. required for it and only for it, see + * AddComponents.throwForWorkspaceRootFlagMismatch. + */ + root?: boolean; }; export type AddContext = { @@ -107,6 +112,7 @@ export default class AddComponents { defaultScope?: string; // helpful for out-of-sync config?: Config; shouldHandleOutOfSync?: boolean; // only bit-add (not bit-create/new) should handle out-of-sync scenario + root?: boolean; constructor(context: AddContext, addProps: AddProps) { this.workspace = context.workspace; this.consumer = context.workspace.consumer; @@ -126,6 +132,37 @@ export default class AddComponents { this.defaultScope = addProps.defaultScope; this.config = addProps.config; this.shouldHandleOutOfSync = addProps.shouldHandleOutOfSync; + this.root = addProps.root; + } + + /** + * tracking the workspace root turns the workspace itself into a component: it owns every file no + * other component claims, `.bitmap` included, and every other component becomes a member of it. + * "bit add ." is one keystroke away from "git add .", which means something else entirely, so the + * intent is spelled out rather than inferred from the path. + * + * only the workspace root needs the flag, and it is the only thing the flag does: "bit add ." from + * a sub-directory names that directory, not the root, and is tracked like any other path. + * + * a workspace that already has a root does not ask again. what the flag guards against is creating + * one by accident, and there is none to create here - the add either refreshes the root that exists + * or names a second owner for ".", which has its own message saying who holds it. + */ + private throwForWorkspaceRootFlagMismatch(resolvedPaths: PathOsBased[]) { + const tracksRoot = resolvedPaths.some( + (onePath) => + (pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(onePath)) || WORKSPACE_ROOT_DIR) === + WORKSPACE_ROOT_DIR + ); + if (tracksRoot && !this.root && !this.bitMap.getWorkspaceRootMap()) { + throw new BitError(`unable to track the workspace root without the --root flag. +it makes the workspace itself a component - it owns every file no other component claims, .bitmap included, and every other component becomes a member of it. +if that is what you want, run "bit add . --root". to track a component, pass its directory, e.g. "bit add my-component"`); + } + if (!tracksRoot && this.root) { + throw new BitError(`the --root flag tracks the workspace root, but none of the given paths is the workspace root. +run "bit add . --root" from the workspace root, or drop the flag to track the given paths as ordinary components`); + } } async add(): Promise { @@ -156,6 +193,7 @@ export default class AddComponents { throw new AddingIndividualFiles(compPath); } }); + this.throwForWorkspaceRootFlagMismatch(Object.keys(componentPathsStats)); if (Object.keys(componentPathsStats).length > 1 && this.id) { throw new BitError( `the --id flag (${this.id}) is used for a single component only, however, got ${this.componentPaths.length} paths` diff --git a/scopes/component/tracker/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts index 7a24b1dc53bf..1420521470a6 100644 --- a/scopes/component/tracker/track-workspace-root.spec.ts +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -65,6 +65,7 @@ describe('tracking the workspace root', function () { id: 'ws-root', main: inWs(tracked, 'README.md'), override: false, + root: true, }); }); after(async () => { @@ -122,6 +123,7 @@ describe('tracking the workspace root', function () { const results = await tracked.tracker.addForCLI({ componentPaths: [WORKSPACE_ROOT_DIR, 'packages/comp1'], override: false, + root: true, }); addedComponents = results.addedComponents.map((added) => ({ id: added.id.toString(), @@ -157,6 +159,7 @@ describe('tracking the workspace root', function () { id: 'ws-root', main: inWs(tracked, 'packages/comp1/index.js'), override: false, + root: true, }); }); after(async () => { @@ -204,4 +207,43 @@ describe('tracking the workspace root', function () { ); }); }); + + describe('the --root flag, which spells out the intent to track the workspace root', () => { + let tracked: Tracked; + // relative paths, as the command gets them: "." only means the workspace root when the cwd is it + const addFrom = async (componentPaths: string[], root?: boolean) => { + const originalCwd = process.cwd(); + process.chdir(tracked.workspacePath); + try { + return await tracked.tracker.addForCLI({ componentPaths, override: false, root }); + } finally { + process.chdir(originalCwd); + } + }; + before(async () => { + tracked = await setupWorkspace({ + 'index.js': 'module.exports = {};\n', + 'packages/comp1/index.js': 'module.exports = () => "comp1";\n', + }); + }); + after(async () => { + await destroyWorkspace(tracked.workspaceData); + }); + it('should refuse the workspace root without it, naming the command that does it', async () => { + await expectToReject(() => addFrom([WORKSPACE_ROOT_DIR]), 'bit add . --root'); + }); + it('should refuse it when none of the paths is the workspace root, rather than ignore it', async () => { + await expectToReject(() => addFrom(['packages/comp1'], true), 'none of the given paths is the workspace root'); + }); + it('should leave an ordinary component alone, the flag is for the root and nothing else', async () => { + const results = await addFrom(['packages/comp1']); + expect(results.addedComponents).to.have.lengthOf(1); + expect(rootDirOf(tracked, 'comp1')).to.equal('packages/comp1'); + }); + it('should track the workspace root with it', async () => { + const results = await addFrom([WORKSPACE_ROOT_DIR], true); + expect(results.addedComponents).to.have.lengthOf(1); + expect(rootDirOf(tracked, results.addedComponents[0].id.toStringWithoutVersion())).to.equal(WORKSPACE_ROOT_DIR); + }); + }); }); From 4f2e1c312d7ec6db6c5a0152c341e256c4640386 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 13:27:34 -0400 Subject: [PATCH 097/102] docs(cli-reference): regenerate for the new bit add --root flag Co-Authored-By: Claude Opus 5 (1M context) --- contrib/claude-skill-bit-cli/CLI_REFERENCE.md | 2 +- scopes/harmony/cli-reference/cli-reference.json | 5 +++++ scopes/harmony/cli-reference/cli-reference.mdx | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/contrib/claude-skill-bit-cli/CLI_REFERENCE.md b/contrib/claude-skill-bit-cli/CLI_REFERENCE.md index 12d5fe7a585c..883aa4242e20 100644 --- a/contrib/claude-skill-bit-cli/CLI_REFERENCE.md +++ b/contrib/claude-skill-bit-cli/CLI_REFERENCE.md @@ -5,7 +5,7 @@ track existing directory contents as new components in the workspace Registers one or more directories as Bit components without changing your files. Each provided path becomes a component root tracked by Bit. -Flags: --id , --main , --namespace , --override , --scope , --env , --json +Flags: --id , --main , --namespace , --override , --scope , --env , --root, --json ## bit app [sub-command] diff --git a/scopes/harmony/cli-reference/cli-reference.json b/scopes/harmony/cli-reference/cli-reference.json index 47b27ac0cc70..d8e5819efde6 100644 --- a/scopes/harmony/cli-reference/cli-reference.json +++ b/scopes/harmony/cli-reference/cli-reference.json @@ -1855,6 +1855,11 @@ "env ", "set the component's environment. (overrides the env from variants if exists)" ], + [ + "", + "root", + "track the workspace root itself as a component, which owns every file no other component claims" + ], [ "j", "json", diff --git a/scopes/harmony/cli-reference/cli-reference.mdx b/scopes/harmony/cli-reference/cli-reference.mdx index 0bc2ec86716b..e0771b8bfde8 100644 --- a/scopes/harmony/cli-reference/cli-reference.mdx +++ b/scopes/harmony/cli-reference/cli-reference.mdx @@ -34,6 +34,7 @@ Registers one or more directories as Bit components without changing your files. | `--override ` | `-o` | override existing component if exists (default = false) | | `--scope ` | `-s` | sets the component's scope. if not entered, the default-scope from workspace.jsonc will be used | | `--env ` | `-e` | set the component's environment. (overrides the env from variants if exists) | +| `--root` | | track the workspace root itself as a component, which owns every file no other component claims | | `--json` | `-j` | output as json format | --- From 113ad101e0dff034a4550d9e6dc13fb6f7574de7 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 14:40:55 -0400 Subject: [PATCH 098/102] fix(workspace-root): close three write and tracking holes found in review the symlink guard for a root write now accounts for the config file the writer enables by itself when one is already at the rootDir; an explicit main file is checked against the scan exclusions, not only the ignore rules, so it cannot be tracked and then dropped by the next rescan; a merge snap brings a new or modified root along as tag and snap do; and the watcher keeps ignoring the never-tracked files when trackAllFiles is on. Co-Authored-By: Claude Opus 5 (1M context) --- components/legacy/bit-map/bit-map.spec.ts | 9 +++++ components/legacy/bit-map/component-map.ts | 13 +++++--- e2e/harmony/add-harmony.e2e.ts | 22 ------------- .../component-writer.main.runtime.ts | 10 ++++-- .../component-writer/component-writer.spec.ts | 13 +++++++- .../snapping/snapping.main.runtime.ts | 14 ++++++-- scopes/component/tracker/add-components.ts | 13 +++++++- .../tracker/track-workspace-root.spec.ts | 33 +++++++++++++++++++ scopes/workspace/watcher/watcher.spec.ts | 6 ++++ scopes/workspace/watcher/watcher.ts | 14 ++++++-- 10 files changed, 113 insertions(+), 34 deletions(-) diff --git a/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index e987b629b06d..736b5d21949e 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -507,5 +507,14 @@ ${JSON.stringify( expect(filtered).to.include('docs/sub/keep.log'); expect(filtered).to.not.include('docs/a.log'); }); + it('should keep applying the workspace rules once a nested ignore file joins them', async () => { + // the two are evaluated as one list, so the workspace's own rules have to survive being + // combined - passing an empty matcher here would let this pass without them + const workspaceRules = ignore().add(['*.secret']); + const withSecret = [...paths, 'docs/token.secret', 'docs/sub/keep.log']; + const filtered = await filterByIgnoreFiles(WORKSPACE_ROOT_DIR, tmpDir, workspaceRules, withSecret); + expect(filtered).to.not.include('docs/token.secret'); + expect(filtered).to.include('docs/sub/keep.log'); + }); }); }); diff --git a/components/legacy/bit-map/component-map.ts b/components/legacy/bit-map/component-map.ts index 30da85e54002..cb135824d547 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -163,11 +163,16 @@ export async function filterByOwnIgnoreFile( } /** - * the paths a scan never yields - bit's own dirs, git's, a nested workspace map - applied to - * workspace-relative paths a caller resolved itself, so what it tracks is what the next rescan keeps. + * the paths a scan never yields - bit's own dirs, git's, a nested workspace map, and the root-dirs + * given in `excludeDirs` - applied to workspace-relative paths a caller resolved itself, so what it + * tracks is what the next rescan keeps. */ -export function filterByScanIgnorePatterns(dir: PathLinux, workspaceRelativePaths: PathLinux[]): PathLinux[] { - return ignore().add(getScanIgnorePatterns(dir)).filter(workspaceRelativePaths); +export function filterByScanIgnorePatterns( + dir: PathLinux, + workspaceRelativePaths: PathLinux[], + excludeDirs: PathLinux[] = [] +): PathLinux[] { + return ignore().add(getScanIgnorePatterns(dir, excludeDirs)).filter(workspaceRelativePaths); } async function getNestedIgnorePatterns( diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index da2775fa8469..101def8d7c45 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -39,28 +39,6 @@ describe('add command on Harmony', function () { helper.general.expectToThrow(cmd, error); }); }); - describe('tracking the workspace root without the --root flag', () => { - let output: string; - before(() => { - helper.scopeHelper.reInitWorkspace(); - helper.fs.outputFile('README.md', '# workspace root\n'); - output = helper.general.runWithTryCatch('bit add .'); - }); - it('should refuse, and name the command that does it', () => { - // "bit add ." is one keystroke from "git add .", so the intent is spelled out rather than assumed - expect(output).to.have.string('bit add . --root'); - }); - it('should leave the workspace root untracked', () => { - const entries: any[] = Object.values(helper.bitMap.read()); - expect(entries.some((entry) => entry?.rootDir === '.')).to.be.false; - }); - it('should refuse the flag when the path is not the workspace root, rather than ignore it', () => { - helper.fs.outputFile('comp1/index.js', 'module.exports = () => "comp1";\n'); - expect(helper.general.runWithTryCatch('bit add comp1 --root')).to.have.string( - 'none of the given paths is the workspace root' - ); - }); - }); describe('adding the workspace root as a component', () => { let rootFiles: string[]; before(() => { diff --git a/scopes/component/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 9c5f6de77a39..b4b20fd04070 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -396,8 +396,14 @@ run "bit remove ${component.id.toStringWithoutVersion()}" first if the workspace const pathsInTheWay = new Set(); const landing = this.filesThatWouldLand(component).map((file) => pathNormalizeToLinux(file.relative)); // the config file is generated rather than versioned, so it is not among the component's files - - // it still lands at the root, and this rule is about where a write goes - if (writeConfig) landing.push(COMPONENT_CONFIG_FILE_NAME); + // it still lands at the root, and this rule is about where a write goes. the flag the caller + // passed does not settle whether it lands: the writer turns config writing on by itself when a + // config file is already at the rootDir (see populateComponentsFilesToWrite), which a symlink + // pointing at an existing file reads as. it is looked up the same way, so a broken one - which + // the writer would not write through either - does not fail the write for nothing. + if (writeConfig || fs.existsSync(this.consumer.toAbsolutePath(COMPONENT_CONFIG_FILE_NAME))) { + landing.push(COMPONENT_CONFIG_FILE_NAME); + } landing.forEach((relativePath) => { const segments = relativePath.split('/'); segments.forEach((_, index) => pathsInTheWay.add(segments.slice(0, index + 1).join('/'))); diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 297ff19a9c0b..1b10ff005bcc 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -140,11 +140,22 @@ describe('the workspace-root import preflight checks', () => { expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']), true)).to.throw('is a symbolic link'); }); - it('should leave it alone when no config file is written', async () => { + it('should refuse it even when the caller asked for no config file, the writer turns it on itself', async () => { + // a config file already at the rootDir makes the writer write one whatever the caller passed + // (see populateComponentsFilesToWrite), and a symlink pointing at an existing file is read as + // one. so the flag does not settle whether a write lands here. const target = path.join(workspacePath, 'elsewhere'); await fs.ensureDir(target); await fs.symlink(target, path.join(workspacePath, 'component.json')); const main = runtimeFor([], workspacePath); + expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']), false)).to.throw('is a symbolic link'); + }); + + it('should leave a broken symlink alone, the writer does not write through it either', async () => { + // it resolves to nothing, so the writer does not read it as an existing config file and no + // config is written. failing the whole import over it would be for nothing. + await fs.symlink(path.join(workspacePath, 'missing'), path.join(workspacePath, 'component.json')); + const main = runtimeFor([], workspacePath); expect(() => main.throwForSymlinksInTheWay(componentFor(['README.md']), false)).to.not.throw(); }); diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index b0a4bc62fa3d..4575f076837b 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -742,13 +742,22 @@ in case you're unsure about the pattern syntax, use "bit pattern [--help]"`); if (!visibleIds.length && !hiddenLegacyComponents.length) return null; this.logger.debug(`snapForMerge, visible: ${visibleIds.length}, hidden: ${hiddenLegacyComponents.length}`); - const visibleHarmony = visibleIds.length ? await this.loadComponentsForTagOrSnap(visibleIds) : []; + // a merge snap is a snap: a new or modified root joins it the way it joins tag and snap, so the + // members snapped here record the root version their files were merged with, rather than the one + // the root was last snapped at (see getWorkspaceRootToTagAlong). only the visible ids can take + // it - a hidden entry is scope-only, with no workspace state to snap the root against. the root + // was snapped as a result of the merge, so it is reported in that section like the rest. + const autoAddedWorkspaceRoot = await this.getWorkspaceRootToTagAlong(visibleIds); + const visibleIdsToSnap = autoAddedWorkspaceRoot + ? ComponentIdList.fromArray([...visibleIds, autoAddedWorkspaceRoot]) + : visibleIds; + const visibleHarmony = visibleIdsToSnap.length ? await this.loadComponentsForTagOrSnap(visibleIdsToSnap) : []; const hiddenHarmony = hiddenLegacyComponents.length ? await this.scope.getManyByLegacy(hiddenLegacyComponents) : []; // issue checks are workspace-source-tree concerns — hidden entries are scope-only if (visibleHarmony.length) await this.throwForVariousIssues(visibleHarmony); const hiddenIds = ComponentIdList.fromArray(hiddenLegacyComponents.map((c) => c.componentId)); - const allIds = ComponentIdList.uniqFromArray([...visibleIds, ...hiddenIds]); + const allIds = ComponentIdList.uniqFromArray([...visibleIdsToSnap, ...hiddenIds]); const allComponents = [...visibleHarmony, ...hiddenHarmony]; const makeVersionParams = { @@ -762,6 +771,7 @@ in case you're unsure about the pattern syntax, use "bit pattern [--help]"`); isSnap: true, packageManagerConfigRootDir: this.workspace.path, loose, + autoAddedWorkspaceRoot, }; const { taggedComponents, autoTaggedResults, stagedConfig, removedComponents } = await this.makeVersion( diff --git a/scopes/component/tracker/add-components.ts b/scopes/component/tracker/add-components.ts index a5e1f49eca8a..68f8aa03849a 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -656,7 +656,18 @@ you can add the directory these files are located at and it'll change the root d // as generated (tsconfig.json and friends) reach it that way, as does a component ignore file. if (resolvedMainFile) { const mainNormalized = pathNormalizeToLinux(resolvedMainFile); - if (matchesNotIgnored.includes(mainNormalized) && !isTrackable(mainNormalized)) { + // it is in the file-set only if it was found on disk. a missing one is reported further down, + // where the error says so rather than blaming an ignore rule. + const inFileSet = filteredMatchedFiles.some((file) => file.relativePath === mainNormalized); + // some paths the scan never yields at all - bit's own dirs, a nested workspace map, the + // root-dir of a component nested in this one - so they never reach `matches` and the ignore + // rules below have nothing to compare them against. either way an explicit main file is put + // into the file-set without going through the scan, and the next rescan drops it again: the + // component then fails to load, saying the main file was removed. + const excludedFromScan = !filterByScanIgnorePatterns(relativeComponentPath, [mainNormalized], nestedRootDirs) + .length; + const excludedByIgnoreRules = matchesNotIgnored.includes(mainNormalized) && !isTrackable(mainNormalized); + if ((inFileSet && excludedFromScan) || excludedByIgnoreRules) { throw new ExcludedMainFile(relativeToComponent(mainNormalized)); } } diff --git a/scopes/component/tracker/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts index 1420521470a6..5f4bb0080c20 100644 --- a/scopes/component/tracker/track-workspace-root.spec.ts +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -208,6 +208,39 @@ describe('tracking the workspace root', function () { }); }); + describe('giving the workspace root a main file inside a component nested in it', () => { + let tracked: Tracked; + before(async () => { + tracked = await setupWorkspace({ + 'index.js': 'module.exports = {};\n', + 'packages/comp1/index.js': 'module.exports = () => "comp1";\n', + }); + await tracked.tracker.addForCLI({ + componentPaths: [inWs(tracked, 'packages/comp1')], + id: 'comp1', + override: false, + }); + }); + after(async () => { + await destroyWorkspace(tracked.workspaceData); + }); + it('should refuse it, the scan of the root never yields a file of a component nested in it', async () => { + // the dir belongs to comp1, so the root's file-set excludes it. tracked anyway, the entry would + // survive until the next rescan dropped the file and the component failed to load without it + await expectToReject( + () => + tracked.tracker.addForCLI({ + componentPaths: [tracked.workspacePath], + id: 'ws-root', + main: inWs(tracked, 'packages/comp1/index.js'), + override: false, + root: true, + }), + 'was excluded from file list' + ); + }); + }); + describe('the --root flag, which spells out the intent to track the workspace root', () => { let tracked: Tracked; // relative paths, as the command gets them: "." only means the workspace root when the cwd is it diff --git a/scopes/workspace/watcher/watcher.spec.ts b/scopes/workspace/watcher/watcher.spec.ts index 72ab59cd0d86..d8a7867e511c 100644 --- a/scopes/workspace/watcher/watcher.spec.ts +++ b/scopes/workspace/watcher/watcher.spec.ts @@ -17,4 +17,10 @@ describe('watchIgnorePatterns', () => { it('should always ignore node_modules and the local scope', () => { expect(watchIgnorePatterns('.bit', true)).to.include.members(['**/node_modules/**', '**/.bit/**']); }); + it('should keep ignoring the never-tracked files when every file is tracked', () => { + // trackAllFiles takes back the files bit generates, not the ones no scan ever yields. a + // workspace-root component owns the whole tree, so a .env save would otherwise be reported as a + // change to a component that cannot list it + expect(watchIgnorePatterns('.bit', true)).to.include.members(['**/.env', '**/.DS_Store']); + }); }); diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index 3e9b59160335..00237642b92c 100644 --- a/scopes/workspace/watcher/watcher.ts +++ b/scopes/workspace/watcher/watcher.ts @@ -3,7 +3,13 @@ import fs from 'fs-extra'; import { dirname, basename, join, relative } from 'path'; import { compact, difference, partition } from 'lodash'; import type { ComponentID, ComponentIdList } from '@teambit/component-id'; -import { BIT_GENERATED_IGNORE_LIST, BIT_MAP, IGNORE_ROOT_ONLY_LIST, WORKSPACE_JSONC } from '@teambit/legacy.constants'; +import { + ALWAYS_IGNORE_LIST, + BIT_GENERATED_IGNORE_LIST, + BIT_MAP, + IGNORE_ROOT_ONLY_LIST, + WORKSPACE_JSONC, +} from '@teambit/legacy.constants'; import type { Consumer } from '@teambit/legacy.consumer'; import { logger } from '@teambit/legacy.logger'; import type { PathOsBasedAbsolute } from '@teambit/legacy.utils'; @@ -1151,6 +1157,10 @@ export class Watcher { * - editing e.g. `packages/comp/tsconfig.json` still reaches the watcher and is then found to be * outside the component's file-set. covering every component root needs these patterns rebuilt * whenever a component is added or moved, which the watcher does not do today. + * + * the never-tracked paths (secrets, os artifacts, installed packages) are ignored whatever the flag + * says. trackAllFiles moves the generated files back into the file-set, and those are never in it - + * no scan yields them, so an edit of one has no component to belong to either way. */ export function watchIgnorePatterns(relScopePath: string, trackAllFiles?: boolean): string[] { const generated = trackAllFiles @@ -1159,5 +1169,5 @@ export function watchIgnorePatterns(relScopePath: string, trackAllFiles?: boolea ...BIT_GENERATED_IGNORE_LIST.map((pattern) => (pattern.includes('/') ? pattern : `**/${pattern}`)), ...IGNORE_ROOT_ONLY_LIST, ]; - return ['**/node_modules/**', ...generated, `**/${relScopePath}/**`]; + return [...ALWAYS_IGNORE_LIST, ...generated, `**/${relScopePath}/**`]; } From 49aefcb8931ed841af872403a5558b8ee024eadd Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 15:43:34 -0400 Subject: [PATCH 099/102] fix(tracker): let the programmatic api track the workspace root without the flag naming "." as the rootDir is already the intent, as a resolved track-data entry declaring it is. the flag is for "bit add", where the path can be typed out of git habit - asking a caller of track() for it answered with a command it is not running. also covers the clone report for members their remote does not have, and pins that a component tracked at the root skips the dir-conflict check like any other tracked directory. Co-Authored-By: Claude Opus 5 (1M context) --- .../component-writer/component-writer.spec.ts | 9 +++++++ .../tracker/track-workspace-root.spec.ts | 17 +++++++++++++ .../component/tracker/tracker.main.runtime.ts | 8 ++++++ scopes/workspace/workspace-root/clone.spec.ts | 25 +++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/scopes/component/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts index 1b10ff005bcc..41d76167bd4f 100644 --- a/scopes/component/component-writer/component-writer.spec.ts +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -288,6 +288,15 @@ describe('deciding whether the directory-conflict check applies to a component', it('should skip everything when nothing is written to the filesystem at all', () => { expect(shouldSkip({ skipWritingToFs: true, writeToPath: 'some-dir' }, 'other-dir')).to.be.true; }); + + it('should treat a component already tracked at the workspace root as any other tracked directory', () => { + // re-importing a tracked component updates the files it owns, and the root is not an exception + // to that. what needs --override is a component arriving at a root someone else's files are at, + // which has no map entry yet - the !componentMap branch of throwErrorWhenDirectoryNotEmpty is + // where that is decided, and this skip never reaches past it. + expect(shouldSkip({}, WORKSPACE_ROOT_DIR, WORKSPACE_ROOT_DIR)).to.be.true; + expect(shouldSkip({ writeToPath: WORKSPACE_ROOT_DIR }, WORKSPACE_ROOT_DIR, WORKSPACE_ROOT_DIR)).to.be.true; + }); }); describe('the destination of an already tracked workspace-root component', () => { diff --git a/scopes/component/tracker/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts index 5f4bb0080c20..5db9dc678463 100644 --- a/scopes/component/tracker/track-workspace-root.spec.ts +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -241,6 +241,23 @@ describe('tracking the workspace root', function () { }); }); + describe('tracking the workspace root through the programmatic api', () => { + let tracked: Tracked; + before(async () => { + tracked = await setupWorkspace({ 'README.md': '# workspace root\n' }); + }); + after(async () => { + await destroyWorkspace(tracked.workspaceData); + }); + it('should take the given rootDir as the intent, the flag is for the command line', async () => { + // a caller naming the workspace root has already said so, as a resolved track-data entry + // declaring "." does. asking it for the flag would answer "run bit add . --root", which is + // not the command it is running + await tracked.tracker.track({ rootDir: tracked.workspacePath, componentName: 'ws-root' }); + expect(rootDirOf(tracked, 'ws-root')).to.equal(WORKSPACE_ROOT_DIR); + }); + }); + describe('the --root flag, which spells out the intent to track the workspace root', () => { let tracked: Tracked; // relative paths, as the command gets them: "." only means the workspace root when the cwd is it diff --git a/scopes/component/tracker/tracker.main.runtime.ts b/scopes/component/tracker/tracker.main.runtime.ts index 4790cb7486b2..3d7dbe6203c2 100644 --- a/scopes/component/tracker/tracker.main.runtime.ts +++ b/scopes/component/tracker/tracker.main.runtime.ts @@ -34,6 +34,13 @@ export type TrackData = { mainFile?: string; // if empty, attempts will be made to guess the best candidate defaultScope?: string; // can be entered as part of "bit create" command, helpful for out-of-sync logic config?: { [aspectName: string]: any }; // config specific to this component, which overrides variants of workspace.jsonc + /** + * tracking the workspace root itself. defaults to whether `rootDir` is it: naming it here is + * already the intent, the way a resolved track-data entry declaring "." is. the flag "bit add" + * asks for is for the command line, where "." can be typed out of git habit and means something + * else entirely (see AddComponents.throwForWorkspaceRootFlagMismatch). + */ + root?: boolean; }; /** @@ -73,6 +80,7 @@ export class TrackerMain { override: false, defaultScope, config: trackData.config, + root: trackData.root ?? compPath === pathNormalizeToLinux(this.workspace.path), } ); const result = await addComponent.add(); diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index 0e39a90ed773..5090728f504a 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -10,6 +10,8 @@ import { resolveThroughExistingAncestors, topmostAbsentDir, } from './clone'; +import type { CloneResult } from './clone'; +import { formatCloneResult } from './clone.cmd'; import { WorkspaceRootMain } from './workspace-root.main.runtime'; describe('resolveClonePath', () => { @@ -194,3 +196,26 @@ describe('clone from a workspace', () => { throw new Error('expected clone to throw'); }); }); + +describe('formatCloneResult', () => { + const resultWith = (missing: string[]): CloneResult => ({ + rootId: ComponentID.fromString('my-org.my-scope/my-root'), + workspacePath: '/tmp/my-root', + components: [ComponentID.fromString('my-org.my-scope/comp1')], + missing, + }); + + it('should name the components the root lists that their remote does not have', () => { + // the clone succeeded without them, so the summary still reports it - the missing ones are their + // own section, and a user who reads only the summary would not know the workspace is short + const output = formatCloneResult(resultWith(['my-org.my-scope/comp2']), 'my-root'); + expect(output).to.have.string('my-org.my-scope/comp2'); + expect(output).to.have.string('not on their remote'); + expect(output).to.have.string('cloned my-org.my-scope/my-root'); + }); + + it('should leave the section out when the root got everything it lists', () => { + const output = formatCloneResult(resultWith([]), 'my-root'); + expect(output).to.not.have.string('not on their remote'); + }); +}); From 9e2becdc12329f57aa895c0ed8a5eb76a9635236 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 15:50:39 -0400 Subject: [PATCH 100/102] fix(snapping): keep a hidden-only merge from snapping the workspace root the tag-along is for the members a merge snaps. a batch of hidden lane entries has no workspace state to snap the root against and records no root, so there is nothing for a root version to make right. Co-Authored-By: Claude Opus 5 (1M context) --- scopes/component/snapping/snapping.main.runtime.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 4575f076837b..759f69589b1d 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -745,9 +745,10 @@ in case you're unsure about the pattern syntax, use "bit pattern [--help]"`); // a merge snap is a snap: a new or modified root joins it the way it joins tag and snap, so the // members snapped here record the root version their files were merged with, rather than the one // the root was last snapped at (see getWorkspaceRootToTagAlong). only the visible ids can take - // it - a hidden entry is scope-only, with no workspace state to snap the root against. the root + // it - a hidden entry is scope-only, with no workspace state to snap the root against and no + // record of it to make right, so a merge of nothing else leaves the root where it is. the root // was snapped as a result of the merge, so it is reported in that section like the rest. - const autoAddedWorkspaceRoot = await this.getWorkspaceRootToTagAlong(visibleIds); + const autoAddedWorkspaceRoot = visibleIds.length ? await this.getWorkspaceRootToTagAlong(visibleIds) : undefined; const visibleIdsToSnap = autoAddedWorkspaceRoot ? ComponentIdList.fromArray([...visibleIds, autoAddedWorkspaceRoot]) : visibleIds; From bb6f38396693af992a97061c528fca64713c2aba Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 16:02:33 -0400 Subject: [PATCH 101/102] fix(workspace-root): refuse member directories a remote map should not name the versioned .bitmap comes from a remote, so a member is no longer written into a directory bit or git keeps for itself (.bit holds the objects the clone reads from), nor into one another member already owns - the writer undoes its own relocation, so the later component would land on the earlier one's files. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/harmony/add-harmony.e2e.ts | 4 ++ scopes/workspace/workspace-root/clone.spec.ts | 32 +++++++++++++ scopes/workspace/workspace-root/clone.ts | 46 +++++++++++++++++-- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/e2e/harmony/add-harmony.e2e.ts b/e2e/harmony/add-harmony.e2e.ts index 101def8d7c45..2f4d5e9b27f6 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -597,6 +597,10 @@ describe('add command on Harmony', function () { helper.scopes.e2eDir ); }); + after(() => { + // it is a workspace of its own, made outside the helper, so it is not in what the helper clears + fs.removeSync(clonePath); + }); it('should write the root files of the version asked for, not of its head on the lane', () => { expect(rootMainHead).to.not.equal(rootLaneHead); expect(helper.bitMap.read(path.join(clonePath, '.bitmap'))['ws-root'].version).to.equal(rootMainHead); diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts index 5090728f504a..dd9246021abe 100644 --- a/scopes/workspace/workspace-root/clone.spec.ts +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -8,6 +8,7 @@ import { resolveClonePath, resolveComponentDir, resolveThroughExistingAncestors, + throwForOverlappingDirs, topmostAbsentDir, } from './clone'; import type { CloneResult } from './clone'; @@ -64,6 +65,17 @@ describe('resolveComponentDir', () => { const resolve = () => resolveComponentDir(workspacePath, { id: 'a' }); expect(resolve).to.throw('not a directory inside the workspace'); }); + it('should refuse a directory bit or git keeps for itself, the map came from a remote', () => { + // .bit holds the objects this very clone is being read from + ['.bit/evil', '.git/hooks', 'node_modules/evil', '.bitTmp/x'].forEach((rootDir) => { + const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir }); + expect(resolve, rootDir).to.throw('not a directory inside the workspace'); + }); + }); + it('should refuse one of them at any depth, not only at the workspace root', () => { + const resolve = () => resolveComponentDir(workspacePath, { id: 'a', rootDir: 'packages/node_modules/a' }); + expect(resolve).to.throw('not a directory inside the workspace'); + }); it('should accept a directory whose name starts with dots, it is not a way out', () => { expect(resolveComponentDir(workspacePath, { id: 'a', rootDir: '..cache' })).to.equal( path.join(workspacePath, '..cache') @@ -219,3 +231,23 @@ describe('formatCloneResult', () => { expect(output).to.not.have.string('not on their remote'); }); }); + +describe('throwForOverlappingDirs', () => { + const ws = path.resolve(os.tmpdir(), 'ws'); + it('should accept members in directories of their own, the ordinary case', () => { + const dirs = { a: path.join(ws, 'comps/a'), b: path.join(ws, 'comps/b') }; + expect(() => throwForOverlappingDirs(dirs)).to.not.throw(); + }); + it('should refuse two members sharing a directory, the later one would land on the earlier', () => { + const dirs = { a: path.join(ws, 'comps/a'), b: path.join(ws, 'comps/a') }; + expect(() => throwForOverlappingDirs(dirs)).to.throw('overlaps the directory'); + }); + it('should refuse a member inside another member, whatever the depth', () => { + const dirs = { a: path.join(ws, 'comps/a'), b: path.join(ws, 'comps/a/src/b') }; + expect(() => throwForOverlappingDirs(dirs)).to.throw('overlaps the directory'); + }); + it('should not read a shared prefix as containment', () => { + const dirs = { a: path.join(ws, 'comps/a'), b: path.join(ws, 'comps/a-b') }; + expect(() => throwForOverlappingDirs(dirs)).to.not.throw(); + }); +}); diff --git a/scopes/workspace/workspace-root/clone.ts b/scopes/workspace/workspace-root/clone.ts index 7ddbf4ca36a9..ca9205936b0e 100644 --- a/scopes/workspace/workspace-root/clone.ts +++ b/scopes/workspace/workspace-root/clone.ts @@ -12,6 +12,7 @@ import { InstallAspect } from '@teambit/install'; import { LaneId } from '@teambit/lane-id'; import type { VersionedBitmapEntry } from '@teambit/legacy.bit-map'; import { isWorkspaceMapFile, readVersionedBitmapEntries, WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +import { BIT_HIDDEN_DIR, BIT_WORKSPACE_TMP_DIRNAME, DOT_GIT_DIR } from '@teambit/legacy.constants'; import { pathNormalizeToLinux } from '@teambit/legacy.utils'; import type { ScopeMain } from '@teambit/scope'; import { ScopeAspect } from '@teambit/scope'; @@ -184,6 +185,7 @@ class WorkspaceCloner { if (entry.rootDir === WORKSPACE_ROOT_DIR) return; writeToPathPerId[entry.id] = resolveComponentDir(this.workspacePath, entry); }); + throwForOverlappingDirs(writeToPathPerId); const ids = Object.keys(writeToPathPerId); if (!ids.length) return { components: [], missing: [] }; const { importedIds, missingIds } = await this.importer.import({ @@ -279,11 +281,20 @@ export async function cloneWorkspace( } } +/** + * the directories a component is never written into, at any depth: bit's own object store and temp + * dir, git's, and the installed packages. they are the machinery of the workspace being made rather + * than part of it, and the scan that builds a component's file-set skips them for the same reason + * (see SCAN_IGNORE_LIST). a map that lists a member in one of them writes into the workings of the + * clone - `.bit` holds the objects the clone is being read from. + */ +const RESERVED_DIRS = [BIT_HIDDEN_DIR, DOT_GIT_DIR, BIT_WORKSPACE_TMP_DIRNAME, 'node_modules']; + /** * the directory of a component the root lists, inside the workspace. the list comes from a remote, * so a root-dir that escapes the workspace - "../x", an absolute path - is refused, and so are the - * workspace root itself, which only the root component owns, and a missing root-dir, which no - * `.bitmap` of the current schema has. + * workspace root itself, which only the root component owns, a directory bit or git keeps for + * itself, and a missing root-dir, which no `.bitmap` of the current schema has. */ export function resolveComponentDir(workspacePath: string, entry: VersionedBitmapEntry): string { const { rootDir } = entry; @@ -298,7 +309,8 @@ export function resolveComponentDir(workspacePath: string, entry: VersionedBitma const relative = target ? path.relative(workspacePath, target) : undefined; // a leading ".." is only a way out when it is the whole segment: a directory may be named "..cache" const climbsOut = relative === '..' || relative?.startsWith(`..${path.sep}`); - if (!target || !relative || climbsOut || path.isAbsolute(relative)) { + const isReserved = relative?.split(path.sep).some((segment) => RESERVED_DIRS.includes(segment)); + if (!target || !relative || climbsOut || isReserved || path.isAbsolute(relative)) { throw new BitError( `unable to clone, the root component lists "${entry.id}" at "${entry.rootDir}", which is not a directory inside the workspace` ); @@ -306,6 +318,34 @@ export function resolveComponentDir(workspacePath: string, entry: VersionedBitma return target; } +/** + * two members the root lists may not share a directory, nor sit one inside another. a `.bitmap` bit + * wrote holds neither - it refuses a duplicate root-dir, and a component under another component's + * directory - but this one came from a remote. left to the writer, a collision is relocated and then + * moved back to the path each component was asked for, so the later one lands on the earlier one's + * files and the clone comes out missing a component it reported as written. + */ +export function throwForOverlappingDirs(dirPerId: Record): void { + const idByDir = new Map(); + const refuse = (id: string, otherId: string, dir: string) => { + throw new BitError( + `unable to clone, the root component lists "${id}" at "${dir}", which overlaps the directory it lists "${otherId}" at` + ); + }; + Object.entries(dirPerId).forEach(([id, dir]) => { + const taken = idByDir.get(dir); + if (taken) refuse(id, taken, dir); + idByDir.set(dir, id); + }); + Object.entries(dirPerId).forEach(([id, dir]) => { + // every level above it, so a component nested any number of levels inside another is caught + for (let parent = path.dirname(dir); parent !== path.dirname(parent); parent = path.dirname(parent)) { + const owner = idByDir.get(parent); + if (owner) refuse(id, owner, dir); + } + }); +} + /** * where the clone lands: the directory given, or one named after the component, as `git clone` names * the working tree after the repository. relative to the cwd, the clone runs outside a workspace. From 4ba95d3227430ca850a053b230189feef734c019 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 18 Sep 2026 16:49:51 -0400 Subject: [PATCH 102/102] revert(watcher): drop the never-tracked names from the watch ignore list it patched five filenames of a general case: a workspace-root component owns the whole tree, so every file at the root it does not track is reported as inside a component that ignores it - git-ignored output included, which the watcher never consulted. naming a handful of them fixes nothing and hides component.json, which people edit by hand. Co-Authored-By: Claude Opus 5 (1M context) --- scopes/workspace/watcher/watcher.spec.ts | 6 ------ scopes/workspace/watcher/watcher.ts | 14 ++------------ 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/scopes/workspace/watcher/watcher.spec.ts b/scopes/workspace/watcher/watcher.spec.ts index d8a7867e511c..72ab59cd0d86 100644 --- a/scopes/workspace/watcher/watcher.spec.ts +++ b/scopes/workspace/watcher/watcher.spec.ts @@ -17,10 +17,4 @@ describe('watchIgnorePatterns', () => { it('should always ignore node_modules and the local scope', () => { expect(watchIgnorePatterns('.bit', true)).to.include.members(['**/node_modules/**', '**/.bit/**']); }); - it('should keep ignoring the never-tracked files when every file is tracked', () => { - // trackAllFiles takes back the files bit generates, not the ones no scan ever yields. a - // workspace-root component owns the whole tree, so a .env save would otherwise be reported as a - // change to a component that cannot list it - expect(watchIgnorePatterns('.bit', true)).to.include.members(['**/.env', '**/.DS_Store']); - }); }); diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index 00237642b92c..3e9b59160335 100644 --- a/scopes/workspace/watcher/watcher.ts +++ b/scopes/workspace/watcher/watcher.ts @@ -3,13 +3,7 @@ import fs from 'fs-extra'; import { dirname, basename, join, relative } from 'path'; import { compact, difference, partition } from 'lodash'; import type { ComponentID, ComponentIdList } from '@teambit/component-id'; -import { - ALWAYS_IGNORE_LIST, - BIT_GENERATED_IGNORE_LIST, - BIT_MAP, - IGNORE_ROOT_ONLY_LIST, - WORKSPACE_JSONC, -} from '@teambit/legacy.constants'; +import { BIT_GENERATED_IGNORE_LIST, BIT_MAP, IGNORE_ROOT_ONLY_LIST, WORKSPACE_JSONC } from '@teambit/legacy.constants'; import type { Consumer } from '@teambit/legacy.consumer'; import { logger } from '@teambit/legacy.logger'; import type { PathOsBasedAbsolute } from '@teambit/legacy.utils'; @@ -1157,10 +1151,6 @@ export class Watcher { * - editing e.g. `packages/comp/tsconfig.json` still reaches the watcher and is then found to be * outside the component's file-set. covering every component root needs these patterns rebuilt * whenever a component is added or moved, which the watcher does not do today. - * - * the never-tracked paths (secrets, os artifacts, installed packages) are ignored whatever the flag - * says. trackAllFiles moves the generated files back into the file-set, and those are never in it - - * no scan yields them, so an edit of one has no component to belong to either way. */ export function watchIgnorePatterns(relScopePath: string, trackAllFiles?: boolean): string[] { const generated = trackAllFiles @@ -1169,5 +1159,5 @@ export function watchIgnorePatterns(relScopePath: string, trackAllFiles?: boolea ...BIT_GENERATED_IGNORE_LIST.map((pattern) => (pattern.includes('/') ? pattern : `**/${pattern}`)), ...IGNORE_ROOT_ONLY_LIST, ]; - return [...ALWAYS_IGNORE_LIST, ...generated, `**/${relScopePath}/**`]; + return ['**/node_modules/**', ...generated, `**/${relScopePath}/**`]; }