diff --git a/.bitmap b/.bitmap index 6d2ed3f0c016..b25dce410f66 100644 --- a/.bitmap +++ b/.bitmap @@ -2319,6 +2319,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/components/legacy/bit-map/bit-map.spec.ts b/components/legacy/bit-map/bit-map.spec.ts index 399c6dfabe12..736b5d21949e 100644 --- a/components/legacy/bit-map/bit-map.spec.ts +++ b/components/legacy/bit-map/bit-map.spec.ts @@ -1,11 +1,19 @@ 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'; import { BitId } from '@teambit/legacy-bit-id'; +import { Extensions } from '@teambit/legacy.constants'; import { logger } from '@teambit/legacy.logger'; -import { BitMap } from './bit-map'; +import { + BitMap, + fileContentsForVersioning, + normalizeBitmapContentForVersioning, + readVersionedBitmapEntries, +} from './bit-map'; +import { filterByIgnoreFiles, WORKSPACE_ROOT_DIR } from './component-map'; import { DuplicateRootDir } from './exceptions/duplicate-root-dir'; const getBitmapInstance = async () => { @@ -93,6 +101,357 @@ 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([]); + }); + 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 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('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); + 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( + { + _bit_lane: { id: { name: 'dev', scope: 'my-scope' }, exported: false }, + comp1: { + name: 'comp1', + scope: 'my-scope', + version: '0a14284ddaadde623d5c11f5511594485a14b3c8', + defaultScope: 'my-org.demo', + mainFile: 'index.ts', + rootDir: 'comp1', + config: { 'teambit.envs/envs': { env: 'teambit.harmony/node' } }, + nextVersion: { version: 'patch', message: 'soft-tagged' }, + }, + '$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 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 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'); + }); + 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'); + }); + 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' }, + 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 */ +${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([]); + }); + 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 = { + 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'); + // 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(); + 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( + 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); + }); + }); + 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; + }); + }); describe('trackDirectoryChanges', () => { const compId = ComponentID.fromObject({ name: 'comp1' }, 'my-scope'); let workspaceDir: string; @@ -129,4 +488,33 @@ describe('BitMap', function () { 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'); + }); + 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/bit-map.ts b/components/legacy/bit-map/bit-map.ts index 232ad4071d27..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'; @@ -13,6 +13,7 @@ import { AUTO_GENERATED_MSG, AUTO_GENERATED_STAMP, BIT_MAP, + Extensions, OLD_BIT_MAP, VERSION_DELIMITER, BITMAP_PREFIX_MESSAGE, @@ -28,7 +29,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 } 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'; @@ -66,6 +73,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, @@ -99,15 +107,29 @@ 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) { + private throwForExistingParentDir(id: ComponentID, rootDir: PathLinuxRelative) { const isParentDir = (parent: string, child: string) => { 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) 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 (isSameDir(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 ${ @@ -157,19 +179,27 @@ 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; } - 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) { - 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; - await bitMap.loadFiles(); + bitMap.trackAllFiles = trackAllFiles; + if (mapFileContent && currentLocation) await bitMap.loadFiles(); return bitMap; } @@ -212,14 +242,77 @@ 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 + * 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 && this.ownsItsDirNow(componentMap) + ); + } + + /** + * 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[] { + // 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 + // 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) => 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 + // 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(/\/+$/, '')) + ); + } + + /** + * 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); + 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); + await this.loadFilesOf(componentMap, gitIgnore); componentMap.recentlyTracked = true; } catch (err: any) { componentMap.files = []; @@ -698,11 +791,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; @@ -875,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) @@ -901,7 +1002,7 @@ export class BitMap { */ async trackDirectoryChanges(componentMap: ComponentMap): Promise { const filesBefore = componentMap.getFilesRelativeToConsumer(); - await componentMap.trackDirectoryChangesHarmony(this.projectRoot, this.ignoredFiles); + await this.loadFilesOf(componentMap); const filesAfter = componentMap.getFilesRelativeToConsumer(); const added = difference(filesAfter, filesBefore); const removed = difference(filesBefore, filesAfter); @@ -1035,6 +1136,120 @@ type OutputFileParams = { prefixMessage?: string; }; +/** + * the workspace-root component tracks `.bitmap` so a git-free workspace can be restored from the + * 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. + * + * 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; + 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]; + // 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 (!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; + } + // 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; + delete entry.onLanesOnly; + 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; + }); + 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; + /** every entry of the current schema has one; a consumer refuses an entry without it */ + 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') + // 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 }; + }) + ); +} + +/** + * 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({ filePath, content, 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..283b4a527d5b --- /dev/null +++ b/components/legacy/bit-map/component-map.spec.ts @@ -0,0 +1,203 @@ +import { expect } from 'chai'; +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 { + 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)); + await Promise.all( + Object.entries(files).map(([file, content]) => fs.outputFile(path.join(workspacePath, file), content)) + ); + return workspacePath; +}; + +describe('getFilesByDir', function () { + this.timeout(0); + 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(); + }; + + 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); + } + }); + }); + + 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; + before(async () => { + workspacePath = await createWorkspace('bit-workspace-root-scan-', { + 'README.md': '', + '.bitmap': '', + // 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 + [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': '', + // 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': '', + 'app/l/index.ts': '', + // 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': '', + // 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, + // 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': '', + // "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(() => 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 => { + 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 () => { + 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', + '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 d76d51b3fba2..cb135824d547 100644 --- a/components/legacy/bit-map/component-map.ts +++ b/components/legacy/bit-map/component-map.ts @@ -3,22 +3,28 @@ 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, + BIT_WORKSPACE_TMP_DIRNAME, + DOT_GIT_DIR, + Extensions, + OLD_BIT_MAP, + 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'; 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'; @@ -26,6 +32,196 @@ 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 = '.'; + +/** + * `.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 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. + */ +const SCAN_IGNORE_LIST = [ + '**/node_modules/**', + `**/${BIT_HIDDEN_DIR}/**`, + `**/${DOT_GIT_DIR}`, + `**/${DOT_GIT_DIR}/**`, + `**/${BIT_WORKSPACE_TMP_DIRNAME}/**`, + `**/${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 - 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}`; + +/** + * 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(dir: PathLinux, excludeDirs: PathLinux[] = []): string[] { + return [ + ...SCAN_IGNORE_LIST, + dir === WORKSPACE_ROOT_DIR ? NESTED_WORKSPACE_MAP : `${escapeGlobPath(dir)}/${BIT_MAP}`, + ...excludeDirs.map((excludeDir) => `${escapeGlobPath(excludeDir)}/**`), + ]; +} + +/** + * 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. 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[], + trackAllFiles = false +): Promise { + const filteredByRoot: PathLinux[] = gitIgnore.filter(relativePaths); + if (dir !== WORKSPACE_ROOT_DIR) return filteredByRoot; + const nestedPatterns = await getNestedIgnorePatterns(consumerPath, gitIgnore, relativePaths); + if (!nestedPatterns.length) return keepWorkspaceMapFile(filteredByRoot, relativePaths); + const filteredByUserRules: PathLinux[] = ignore().add(gitIgnore).add(nestedPatterns).filter(relativePaths); + 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; +} + +/** + * the component's own ignore file (.bitignore, else .gitignore, at its root), applied to its files. + * 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, + consumerPath: string, + relativePaths: PathLinux[] +): Promise { + if (dir === WORKSPACE_ROOT_DIR) return relativePaths; + 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, 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[], + excludeDirs: PathLinux[] = [] +): PathLinux[] { + return ignore().add(getScanIgnorePatterns(dir, excludeDirs)).filter(workspaceRelativePaths); +} + +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 []; + // 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( + 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)); + }) + ); + return ([] as string[]).concat(...patternsPerDir); +} + +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('/'); + // a trailing slash means "directories only". the join drops it, so it is put back. + const dirOnly = body.endsWith('/') ? '/' : ''; + const base = anchored ? pathJoinLinux(dir, body.replace(/^\//, '')) : pathJoinLinux(dir, '**', body); + const rebased = base + dirOnly; + return negated ? `!${rebased}` : rebased; +} + +/** 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; /** @@ -261,19 +457,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[]): Promise { - const trackDir = this.rootDir; - if (!trackDir) { - return; - } - const gitIgnore = await getGitIgnoreHarmony(consumerPath, ignoredFiles); - this.files = await getFilesByDir(trackDir, consumerPath, gitIgnore); - } - updateNextVersion(nextVersion: NextVersion) { this.nextVersion = nextVersion; this.validate(); @@ -347,10 +530,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,26 +563,44 @@ 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[] = [], + trackAllFiles = false +): Promise { + 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: [`${dir}/node_modules/`], + 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. + expandDirectories: false, }); 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}/`, '')); - const filteredByIgnoredFromRoot = relativePathsLinux.filter((match) => !IGNORE_ROOT_ONLY_LIST.includes(match)); - const bitOrGitIgnore = filteredByIgnoredFromRoot.includes(BIT_IGNORE) - ? await getBitIgnoreFile(dir) - : await getGitIgnoreFile(dir); - const filteredByBitIgnore = bitOrGitIgnore - ? ignore().add(bitOrGitIgnore).filter(filteredByIgnoredFromRoot) - : filteredByIgnoredFromRoot; + 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 + // 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)); + const filteredByBitIgnore = await filterByOwnIgnoreFile(dir, consumerPath, filteredByIgnoredFromRoot); if (!filteredByBitIgnore.length) throw new IgnoredDirectory(dir); return filteredByBitIgnore.map((relativePath) => ({ relativePath, @@ -410,15 +609,29 @@ export async function getFilesByDir(dir: string, consumerPath: string, gitIgnore })); } -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 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 748bd90ff05d..761c7dec1168 100644 --- a/components/legacy/bit-map/index.ts +++ b/components/legacy/bit-map/index.ts @@ -5,6 +5,10 @@ export { CURRENT_BITMAP_SCHEMA, SCHEMA_FIELD, LANE_KEY, + normalizeBitmapContentForVersioning, + readVersionedBitmapEntries, + VersionedBitmapEntry, + fileContentsForVersioning, } from './bit-map'; export { MissingBitMapComponent, MissingMainFile, InvalidBitMap } from './exceptions'; export { @@ -12,6 +16,13 @@ export { ComponentMapFile, ComponentMap, Config, + filterByIgnoreFiles, + filterByOwnIgnoreFile, + filterByScanIgnorePatterns, + getFilesByDir, getIgnoreListHarmony, + getScanIgnorePatterns, + isWorkspaceMapFile, NextVersion, + WORKSPACE_ROOT_DIR, } from './component-map'; diff --git a/components/legacy/constants/constants.ts b/components/legacy/constants/constants.ts index 40caed7ff040..2536c29be339 100644 --- a/components/legacy/constants/constants.ts +++ b/components/legacy/constants/constants.ts @@ -258,20 +258,31 @@ export const DEFAULT_BIT_ENV = 'production'; export const MergeConfigFilename = 'merge-conflict'; /** + * 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/**', - '**/package-lock.json', - '**/yarn.lock', ]; +/** + * 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 5946568d0772..fa686719c2cf 100644 --- a/components/legacy/consumer-component/consumer-component.ts +++ b/components/legacy/consumer-component/consumer-component.ts @@ -16,6 +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 { 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'; @@ -604,6 +605,7 @@ 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 }); + sourceFile.contents = fileContentsForVersioning(componentMap, file.relativePath, sourceFile.contents); return sourceFile; }); const filePaths = componentMap.getAllFilesPaths(); 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..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'; @@ -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) { @@ -346,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/contrib/claude-skill-bit-cli/CLI_REFERENCE.md b/contrib/claude-skill-bit-cli/CLI_REFERENCE.md index c89b6474e2c4..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] @@ -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 5d6ad73ce2ca..79123c45c535 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 3f24afacc7d7..2f4d5e9b27f6 100644 --- a/e2e/harmony/add-harmony.e2e.ts +++ b/e2e/harmony/add-harmony.e2e.ts @@ -1,9 +1,11 @@ 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'; import chaiFs from 'chai-fs'; chai.use(chaiFs); +const { expect } = chai; describe('add command on Harmony', function () { this.timeout(0); @@ -37,4 +39,585 @@ 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 --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'); + }); + 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 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; + }); + 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('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 --root'); + 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); + }); + 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); + }); + 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 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' }); + }); + it('should let the new component take the files from the root component', () => { + 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', () => { + expect(helper.command.statusJson().modifiedComponents).to.have.lengthOf(1); + }); + 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'); + }); + }); + }); + 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 --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'), ''); + 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. + 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'); + }); + 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('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.fs.outputFile('docs/guide.md', '# guide\n'); + 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'); + 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')]); + }); + 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 + // 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 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'); + }); + 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. + helper.workspaceJsonc.addKeyValToWorkspace('name', 'renamed'); + 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'); + }); + 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(() => { + 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 with a directory or a 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'); + }); + // 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.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')); + 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 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('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 --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; + const issuesOf = (name: string): string[] => { + const comp = status.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'); + helper.command.addComponent('comp1', { i: 'comp1' }); + helper.fs.outputFile('README.md', '# workspace root\n'); + helper.command.addComponent('.', '-i ws-root --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 + // 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'); + }); + it('should not report compiler-derived issues, while a regular component still does', () => { + // 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'); + }); + 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' } }); + 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. + // 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({}); + }); + }); + }); + 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. + 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 --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 + // 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.cleanWorkspace(); + helper.scopeHelper.addRemoteScope(undefined, undefined, true); + output = helper.command.runCmd(`bit clone ${helper.scopes.remote}/ws-root . -x`); + }); + 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 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.have.lengthOf(0); + expect(status.newComponents).to.have.lengthOf(0); + }); + 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(); + }); + 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', () => { + let comp1MainHead: string; + 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 --root'); + helper.command.snapAllComponentsWithoutBuild('--ignore-issues "*"'); + helper.command.export(); + comp1MainHead = helper.command.getHead('comp1'); + comp2MainHead = helper.command.getHead('comp2'); + helper.command.createLane('dev'); + // 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'); + 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`); + }); + 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(); + }); + 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 + ); + }); + 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); + // 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, + }); + }); + }); + }); }); 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/pnpm-lock.yaml b/pnpm-lock.yaml index 234ca33cdabd..dfff2eb4dfae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7597,7 +7597,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@5.2.1)(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/ts-server': specifier: workspace:* version: file:scopes/typescript/ts-server @@ -7685,6 +7685,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) @@ -8665,7 +8668,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 @@ -8753,6 +8756,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) @@ -9721,7 +9727,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 @@ -9809,6 +9815,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) @@ -10777,7 +10786,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 @@ -10865,6 +10874,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) @@ -11815,7 +11827,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 @@ -11903,6 +11915,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) @@ -12853,7 +12868,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 @@ -12941,6 +12956,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) @@ -13897,7 +13915,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 @@ -13985,6 +14003,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) @@ -17028,6 +17049,9 @@ importers: array-difference: specifier: 0.0.2 version: 0.0.2 + chai: + specifier: 5.2.1 + version: 5.2.1 chalk: specifier: 4.1.2 version: 4.1.2 @@ -26698,6 +26722,55 @@ importers: specifier: ^3.0.0 version: 3.0.2 + 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) + '@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 + fs-extra: + specifier: 10.0.0 + version: 10.0.0 + 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) + '@types/fs-extra': + specifier: 9.0.7 + version: 9.0.7 + packages: '@adobe/css-tools@4.5.0': @@ -34770,6 +34843,8 @@ packages: '@teambit/tracker@file:scopes/component/tracker': resolution: {directory: scopes/component/tracker, type: directory} + peerDependencies: + chai: 5.2.1 '@teambit/ts-server@file:scopes/typescript/ts-server': resolution: {directory: scopes/typescript/ts-server, type: directory} @@ -35391,6 +35466,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: @@ -67940,6 +68020,7 @@ snapshots: '@teambit/component-id': 1.2.4 '@teambit/component.sources': file:scopes/component/sources(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@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.constants': file:components/legacy/constants '@teambit/legacy.consumer': file:components/legacy/consumer(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) @@ -67964,6 +68045,7 @@ snapshots: '@teambit/component-id': 1.2.4 '@teambit/component.sources': file:scopes/component/sources(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@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.constants': file:components/legacy/constants '@teambit/legacy.consumer': file:components/legacy/consumer(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) @@ -74903,6 +74985,7 @@ snapshots: '@teambit/component.sources': file:scopes/component/sources(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/harmony': 0.4.12 '@teambit/harmony.modules.in-memory-cache': file:scopes/harmony/modules/in-memory-cache + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.constants': file:components/legacy/constants '@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 @@ -74945,6 +75028,7 @@ snapshots: '@teambit/component.sources': file:scopes/component/sources(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/harmony': 0.4.12 '@teambit/harmony.modules.in-memory-cache': file:scopes/harmony/modules/in-memory-cache + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.constants': file:components/legacy/constants '@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 @@ -74987,6 +75071,7 @@ snapshots: '@teambit/component.sources': file:scopes/component/sources(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/harmony': 0.4.12 '@teambit/harmony.modules.in-memory-cache': file:scopes/harmony/modules/in-memory-cache + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.constants': file:components/legacy/constants '@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 @@ -78771,6 +78856,7 @@ snapshots: '@teambit/component.sources': file:scopes/component/sources(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/harmony': 0.4.12 '@teambit/harmony.modules.feature-toggle': file:scopes/harmony/modules/feature-toggle + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.component-list': file:components/legacy/component-list(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) '@teambit/legacy.constants': file:components/legacy/constants '@teambit/legacy.consumer': file:components/legacy/consumer(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) @@ -81892,7 +81978,44 @@ snapshots: react-dom: 19.2.7(react@19.2.7) react-router-dom: 6.30.4(react-dom@19.2.7)(react@19.2.7) - '@teambit/tracker@file:scopes/component/tracker(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0)': + '@teambit/tracker@file:scopes/component/tracker(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/harmony.modules.concurrency': file:scopes/harmony/modules/concurrency + '@teambit/legacy-bit-id': 1.1.3 + '@teambit/legacy.analytics': file:components/legacy/analytics + '@teambit/legacy.bit-map': file:components/legacy/bit-map(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.constants': file:components/legacy/constants + '@teambit/legacy.consumer': file:components/legacy/consumer(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.0) + '@teambit/legacy.logger': file:components/legacy/logger + '@teambit/legacy.utils': file:components/legacy/utils + '@teambit/toolbox.promise.map-pool': file:scopes/toolbox/promise/map-pool + '@teambit/workspace.modules.node-modules-linker': file:scopes/workspace/modules/node-modules-linker(domexception@4.0.0)(graphql@15.8.0)(supports-color@9.4.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) + array-difference: 0.0.2 + chai: 4.3.0 + chalk: 4.1.2 + firstline: 2.0.2 + fs-extra: 10.0.0 + glob: 13.0.0 + ignore: 3.3.10 + lodash: 4.17.21 + 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) + string-format: 0.5.0 + transitivePeerDependencies: + - domexception + - encoding + - graphql + - supports-color + + '@teambit/tracker@file:scopes/component/tracker(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 @@ -81912,6 +82035,7 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) array-difference: 0.0.2 + chai: 5.2.1 chalk: 4.1.2 firstline: 2.0.2 fs-extra: 10.0.0 @@ -85596,6 +85720,58 @@ 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/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.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) + 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/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.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) + 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/checkout/checkout-version.ts b/scopes/component/checkout/checkout-version.ts index 628bea40b41c..3a8127518cd5 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, 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'; @@ -127,6 +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. 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/component-writer/component-writer.main.runtime.ts b/scopes/component/component-writer/component-writer.main.runtime.ts index 4d7f1008ae40..b4b20fd04070 100644 --- a/scopes/component/component-writer/component-writer.main.runtime.ts +++ b/scopes/component/component-writer/component-writer.main.runtime.ts @@ -19,19 +19,27 @@ 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 { COMPONENT_CONFIG_FILE_NAME } from '@teambit/legacy.constants'; +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'; 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 { 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; @@ -272,15 +280,26 @@ export class ComponentWriterMain { component: ConsumerComponent, opts: ManyComponentsWriterParams ): ComponentWriterProps { - const componentRootDir: PathLinuxRelative = opts.writeToPath - ? pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(path.resolve(opts.writeToPath))) + // "--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 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 }); + // 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); + // the symlink rule is about where a write lands, and --track-only writes nothing (skipWritingToFs) + 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. - if (this.consumer && !opts.writeToEmptyDir) { - this.throwErrorWhenDirectoryNotEmpty(componentRootDir, existingComponentMap, opts); + // 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 { workspace: this.workspace, @@ -292,9 +311,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. @@ -305,8 +334,16 @@ 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) { + // 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); } }); @@ -317,6 +354,7 @@ to move all component files to a different directory, run bit remove and then bi * 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 @@ -324,18 +362,124 @@ to move all component files to a different directory, run bit remove and then bi 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 (!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; } + /** + * 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). + */ + /** + * 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) => { + 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, writeConfig?: boolean) { + 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. 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('/'))); + }); + pathsInTheWay.forEach((pathInTheWay) => { + const stat = lstatIfExists(this.consumer.toAbsolutePath(pathInTheWay)); + if (!stat?.isSymbolicLink()) return; + throw new BitError( + `unable to write "${component.id.toString()}" to the workspace root, "${pathInTheWay}" is a symbolic link and the files would be written 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 + * 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) { + 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` + ); + } + + /** + * 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 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) + ); + const generatedByInit = isFreshWorkspace ? [WORKSPACE_JSONC] : []; + const filesToOverwrite = this.filesThatWouldLand(component) + .filter((file) => { + const relativePath = pathNormalizeToLinux(file.relative); + 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. + 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 fs.readFileSync(absolutePath, 'latin1') !== file.contents.toString('latin1'); + }) + .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, it would overwrite ${shown}${rest}. +use --override to overwrite them` + ); + } + private throwErrorWhenDirectoryNotEmpty( + component: ConsumerComponent, componentDirRelative: PathLinuxRelative, componentMap: ComponentMap | null | undefined, opts: ManyComponentsWriterParams ) { - if (this.shouldSkipDirConflictCheck(componentDirRelative, componentMap, opts)) return; + if (this.shouldSkipDirConflictCheck(component, componentDirRelative, componentMap, opts)) return; const componentDir = this.consumer.toAbsolutePath(componentDirRelative); if (!fs.pathExistsSync(componentDir)) return; @@ -351,6 +495,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`); } + 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` @@ -377,7 +525,10 @@ either use --path to specify a different directory or modify "defaultDirectory" componentWriterInstances.forEach((componentWriter) => { const currentDir = componentWriter.writeToPath; const componentMap = componentWriter.existingComponentMap; - if (this.shouldSkipDirConflictCheck(currentDir, componentMap, opts)) return; + // 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(componentWriter.component, currentDir, componentMap, opts)) return; const unavailableReason = this.getDirUnavailableReason(currentDir, componentMap); if (!unavailableReason) return; @@ -447,3 +598,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/component-writer/component-writer.spec.ts b/scopes/component/component-writer/component-writer.spec.ts new file mode 100644 index 000000000000..41d76167bd4f --- /dev/null +++ b/scopes/component/component-writer/component-writer.spec.ts @@ -0,0 +1,323 @@ +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 { 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'; + +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[], writeToPath = '.') { + 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']); + }); + + 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(['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 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 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 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(); + }); + + 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'); + }); + + 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 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 + * 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('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; + }); + + 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', () => { + 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); + }); +}); diff --git a/scopes/component/component-writer/component-writer.ts b/scopes/component/component-writer/component-writer.ts index c71864e417f0..ed1d9ba294f8 100644 --- a/scopes/component/component-writer/component-writer.ts +++ b/scopes/component/component-writer/component-writer.ts @@ -3,6 +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 type { ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import { DataToPersist, RemovePath } from '@teambit/component.sources'; import type { Consumer } from '@teambit/legacy.consumer'; @@ -27,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; @@ -104,8 +118,15 @@ 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 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(relativePath)) return; + if (isOwnedByNestedComponent(relativePath, nestedRootDirs)) 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 }); @@ -119,9 +140,6 @@ export default class ComponentWriter { } async addComponentToBitMap(rootDir: string): Promise { - if (rootDir === '.') { - throw new Error('addComponentToBitMap: rootDir cannot be "."'); - } 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/remove/remove-components.ts b/scopes/component/remove/remove-components.ts index e09e9e316bbd..127cff90ea0f 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'; @@ -168,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); @@ -184,9 +187,21 @@ If you understand the risks and wish to proceed with the removal, please use the ); } +/** + * 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 pathsToRemoveWithNulls = components.map((c) => { + const linkedComponents = withoutWorkspaceRoot(consumer, components); + const pathsToRemoveWithNulls = linkedComponents.map((c) => { return getNodeModulesPathOfComponent({ ...c, id: c.id }); }); const pathsToRemove = compact(pathsToRemoveWithNulls); 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..759f69589b1d 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(); @@ -727,13 +742,23 @@ 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 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 = visibleIds.length ? await this.getWorkspaceRootToTagAlong(visibleIds) : undefined; + 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 = { @@ -747,6 +772,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( @@ -1398,6 +1424,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 +1600,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-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 45758bb98134..52e56c14713f 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'; @@ -81,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 }; @@ -148,6 +154,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) @@ -418,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; } @@ -515,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()}`); @@ -553,6 +569,7 @@ export class VersionMaker { } 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) @@ -752,6 +769,37 @@ 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. + * 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. + * + * 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); + 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) => { + // 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; + writeWorkspaceRoot(component.extensions, rootId); + }); + } + private async addLogToComponents( components: ConsumerComponent[], autoTagComps: ConsumerComponent[], 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.spec.ts b/scopes/component/tracker/add-components.spec.ts new file mode 100644 index 000000000000..a17c4ea6c65c --- /dev/null +++ b/scopes/component/tracker/add-components.spec.ts @@ -0,0 +1,146 @@ +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'; +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); + + const workspaces: WorkspaceData[] = []; + + /** 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', + }; + + /** 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) + ); + const harmony = await loadManyAspects([WorkspaceAspect, TrackerAspect], workspacePath); + // 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 Promise.all(workspaces.map((workspaceData) => destroyWorkspace(workspaceData))); + }); + + 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' + ); + }); + }); + + 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', + '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 649d17a8f107..68f8aa03849a 100644 --- a/scopes/component/tracker/add-components.ts +++ b/scopes/component/tracker/add-components.ts @@ -9,9 +9,27 @@ 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, + Extensions, + WORKSPACE_JSONC, +} 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, + filterByIgnoreFiles, + filterByOwnIgnoreFile, + filterByScanIgnorePatterns, + getFilesByDir, + getIgnoreListHarmony, + getScanIgnorePatterns, + isWorkspaceMapFile, + 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'; @@ -66,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 = { @@ -89,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; @@ -108,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 { @@ -138,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` @@ -232,17 +288,35 @@ 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.getWorkspaceRootMap(); + 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); + // 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) { + // 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; const existingIdOfFile = this.bitMap.getComponentIdByPath(file.relativePath, caseSensitive); const idOfFileIsDifferent = existingIdOfFile && !existingIdOfFile.isEqual(parsedBitId); - if (idOfFileIsDifferent) { + const ownedByWorkspaceRoot = Boolean( + workspaceRootMap && existingIdOfFile?.isEqualWithoutVersion(workspaceRootMap.id) + ); + if (idOfFileIsDifferent && !ownedByWorkspaceRoot) { // not imported component file but exists in bitmap // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! if (this.warnings.alreadyUsed[existingIdOfFile]) { @@ -278,7 +352,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 { @@ -291,9 +367,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. @@ -315,7 +395,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, @@ -337,6 +420,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}/`) ); @@ -486,23 +574,73 @@ 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) { 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); + // 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([ + ...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, + // 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), }); 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 matchesNotIgnored = await filterByIgnoreFiles( + relativeComponentPath, + this.consumer.getPath(), + this.gitIgnore, + 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) + ) + ); + // 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); @@ -512,11 +650,31 @@ 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. 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); + // 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)); + } + } 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); @@ -549,7 +707,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[]) { @@ -578,7 +736,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]; @@ -628,9 +787,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; @@ -649,13 +811,28 @@ 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) { + 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; + // 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, @@ -720,40 +897,148 @@ 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[] ): Promise { const bitMap = workspace.consumer.bitMap; - const ignoreList = await getIgnoreListHarmony(workspace.path, workspace.consumer.config.ignoredFiles); + const { ignoredFiles, trackAllFiles } = workspace.consumer.config; + const ignoreList = await getIgnoreListHarmony(workspace.path, ignoredFiles, trackAllFiles); const gitIgnore = ignore().add(ignoreList); - 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 filtered = gitIgnore.filter(files); - if (!filtered.length) { - throw new NoFiles(files); - } - - const componentFiles = filtered.map((match: PathOsBased) => { - return { relativePath: pathNormalizeToLinux(match), name: path.basename(match) }; - }); - + // 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: ComponentMap[] = []; + for (const data of trackData) { + 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); + // 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; + // 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; + // 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) + : 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.getWorkspaceRootMap(); + if (!isWorkspaceRoot && workspaceRootMap) throwForTakingWorkspaceRootMainFile(workspaceRootMap, idToTrack, rootDir); const componentMap = bitMap.addComponent({ - componentId: ComponentID.fromObject({ name: componentName }, defaultScope), + componentId: idToTrack, files: componentFiles, - defaultScope, - config, + defaultScope: idToTrack.hasScope() ? undefined : defaultScope, + config: isWorkspaceRoot ? configForWorkspaceRoot(existingConfig, config) : config, mainFile, rootDir, }); - return componentMap; - }); + componentMaps.push(componentMap); + } const allIds = componentMaps.map((c) => c.id); await linkToNodeModulesByIds(workspace, allIds); 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, + rootDir: PathLinux +) { + // 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 "${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}` + ); +} + +/** + * 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 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. + */ +async function filterResolvedFiles( + rootDir: PathLinuxRelative, + consumerPath: string, + files: string[], + gitIgnore: any, + trackAllFiles?: boolean +): Promise { + const notGenerated = files + .map(pathNormalizeToLinux) + .filter((file) => trackAllFiles || !IGNORE_ROOT_ONLY_LIST.includes(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)); + 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 })); +} 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/track-workspace-root.spec.ts b/scopes/component/tracker/track-workspace-root.spec.ts new file mode 100644 index 000000000000..5db9dc678463 --- /dev/null +++ b/scopes/component/tracker/track-workspace-root.spec.ts @@ -0,0 +1,299 @@ +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 { WORKSPACE_ROOT_DIR } from '@teambit/legacy.bit-map'; +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, + root: true, + }); + }); + 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 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, + root: true, + }); + 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 () => { + 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, + root: true, + }); + }); + 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 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'), ''); + await expectToReject( + () => + tracked.tracker.addForCLI({ + componentPaths: [inWs(tracked, 'packages/comp1')], + id: 'comp1', + override: false, + }), + 'main file of the workspace-root component' + ); + }); + }); + + 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('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 + 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); + }); + }); +}); diff --git a/scopes/component/tracker/tracker.main.runtime.ts b/scopes/component/tracker/tracker.main.runtime.ts index 722ca8e42b04..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; }; /** @@ -42,7 +49,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 @@ -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/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 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/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/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/cli-reference/cli-reference.json b/scopes/harmony/cli-reference/cli-reference.json index 244362793428..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", @@ -6440,5 +6445,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 9fa2011d940e..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 | --- @@ -502,6 +503,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/config/workspace-config.ts b/scopes/harmony/config/workspace-config.ts index c4f06c205950..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; @@ -404,6 +405,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/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/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/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/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; 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/eject/components-ejector.ts b/scopes/workspace/eject/components-ejector.ts index 60afa8549ea0..2eb8b781dc21 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'; @@ -135,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 { @@ -164,6 +169,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()); diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 295712a436fb..cab6f673d319 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -207,38 +207,45 @@ export class InstallMain { // against the pre-install dependency state. 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'); @@ -1379,6 +1386,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) { @@ -1551,7 +1561,13 @@ 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). + return ComponentMap.as(components, (component) => this.workspace.componentDir(component.id)).filter( + (componentDir) => componentDir !== this.workspace.path + ); } private async onRootAspectAddedSubscriber(_aspectId: ComponentID, inWs: boolean): 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 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(); 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/**']); + }); +}); diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index 93b4dd2e66f4..3e9b59160335 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,9 +168,10 @@ export class Watcher { return this.workspace.consumer; } - private getParcelIgnorePatterns(): string[] { + /** 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)); - return ['**/node_modules/**', '**/package.json', `**/${relScopePath}/**`]; + return watchIgnorePatterns(relScopePath, this.consumer.config.trackAllFiles); } /** @@ -180,7 +181,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 @@ -920,8 +921,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( @@ -1139,3 +1139,25 @@ 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: 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 + ? [] + : [ + ...BIT_GENERATED_IGNORE_LIST.map((pattern) => (pattern.includes('/') ? pattern : `**/${pattern}`)), + ...IGNORE_ROOT_ONLY_LIST, + ]; + return ['**/node_modules/**', ...generated, `**/${relScopePath}/**`]; +} diff --git a/scopes/workspace/workspace-root/clone.cmd.ts b/scopes/workspace/workspace-root/clone.cmd.ts new file mode 100644 index 000000000000..e3d69ffdc791 --- /dev/null +++ b/scopes/workspace/workspace-root/clone.cmd.ts @@ -0,0 +1,85 @@ +import path from 'path'; +import type { Command, CommandOptions } 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'; + +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()})`) : ''; + // 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( + `${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)) + ); + 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 joinSections([summary, lane, missing, installation, next]); +} diff --git a/scopes/workspace/workspace-root/clone.spec.ts b/scopes/workspace/workspace-root/clone.spec.ts new file mode 100644 index 000000000000..dd9246021abe --- /dev/null +++ b/scopes/workspace/workspace-root/clone.spec.ts @@ -0,0 +1,253 @@ +import { expect } from 'chai'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { ComponentID } from '@teambit/component-id'; +import { + ensureEmptyDir, + resolveClonePath, + resolveComponentDir, + resolveThroughExistingAncestors, + throwForOverlappingDirs, + topmostAbsentDir, +} from './clone'; +import type { CloneResult } from './clone'; +import { formatCloneResult } from './clone.cmd'; +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', () => { + 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 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'); + }); + 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'); + }); + 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') + ); + }); +}); + +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('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 { + 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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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 new file mode 100644 index 000000000000..ca9205936b0e --- /dev/null +++ b/scopes/workspace/workspace-root/clone.ts @@ -0,0 +1,447 @@ +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'; +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 { 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'; +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; + +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 scope: ScopeMain; + private install: InstallMain; + + constructor( + harmony: Harmony, + private workspacePath: string + ) { + this.workspace = harmony.get(WorkspaceAspect.id); + this.importer = harmony.get(ImporterAspect.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.writeRoot(versionedRootId); + const { components, missing } = await this.writeMembers(entries); + 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, 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('/')) { + throw new BitError( + `unable to clone from lane "${lane}", the lane must be given with its scope, e.g. "my-org.my-scope/${lane}"` + ); + } + 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; + } + + /** + * 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, missingIds } = await this.importer.import({ + ids: [rootId.toString()], + objectsOnly: true, + installNpmPackages: false, + writeConfigFiles: false, + }); + const versionedRootId = importedIds.find((id) => id.isEqualWithoutVersion(rootId)); + 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( + `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 }; + } + + /** + * 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. + * + * 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 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); + }); + throwForOverlappingDirs(writeToPathPerId); + 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 || [] }; + } + + /** + * 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, 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, + dir: string | undefined, + options: CloneOptions, + loadBit: LoadBit +): 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 { + // 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( + 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) { + // leave the directory before removing it, and leave nothing half-made behind: it was empty or + // 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(topmostCreated || 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); + } +} + +/** + * 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, 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; + // 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. + // 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}`); + 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` + ); + } + 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. + */ +export function resolveClonePath(dir: string | undefined, rootId: ComponentID): string { + 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 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. + */ +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 + * 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, + * 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 + */ +export async function ensureEmptyDir(dirPath: string): Promise { + const stat = await lstatIfExists(dirPath); + if (!stat) { + await fs.ensureDir(dirPath); + return true; + } + 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; + } +} diff --git a/scopes/workspace/workspace-root/index.ts b/scopes/workspace/workspace-root/index.ts new file mode 100644 index 000000000000..8383836627cd --- /dev/null +++ b/scopes/workspace/workspace-root/index.ts @@ -0,0 +1,10 @@ +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, + 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..9790ee86d80f --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root-data.spec.ts @@ -0,0 +1,94 @@ +import { expect } from 'chai'; +import { ComponentID } from '@teambit/component-id'; +import { BitMap } from '@teambit/legacy.bit-map'; +import { Extensions } from '@teambit/legacy.constants'; +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'); + +describe('workspace-root data', () => { + describe('findWorkspaceRootMap', () => { + // 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', 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', async () => { + // it stays in .bitmap so a switch back can restore it. snapping here would otherwise tag it along + 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', async () => { + const bitMap = await bitMapWith({ 'my-scope/my-root': rootEntry }); + bitMap.components[0].config = { [Extensions.remove]: { removed: true } }; + 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([]); + 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; + }); + 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 }), + ]); + writeWorkspaceRoot(extensions, rootId); + expect(isWorkspaceRootComponent(extensions)).to.be.false; + 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 new file mode 100644 index 000000000000..3dad937500dd --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root-data.ts @@ -0,0 +1,70 @@ +import { ComponentID } from '@teambit/component-id'; +import type { BitMap, ComponentMap } 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 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 = { + /** + * 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; +}; + +/** + * 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.getWorkspaceRootMap(); +} + +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 = findData(extensions)?.root; + return root ? ComponentID.fromString(root) : undefined; +} + +/** + * 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 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..b530a7ca6cf2 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.docs.mdx @@ -0,0 +1,62 @@ +--- +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 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": { + "data": { "root": "my-org.my-scope/my-root@0.0.7" } +} +``` + +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. + +## 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. + +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. + +## 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..9338563ad100 --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.fragment.ts @@ -0,0 +1,26 @@ +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.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: 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 new file mode 100644 index 000000000000..a7b8b2c499ce --- /dev/null +++ b/scopes/workspace/workspace-root/workspace-root.main.runtime.ts @@ -0,0 +1,109 @@ +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 { 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'; +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: 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. + */ +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. + */ + getRootComponentId(): ComponentID | undefined { + if (!this.workspace) return undefined; + return findWorkspaceRootMap(this.workspace.consumer.bitMap)?.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)); + } + + /** + * 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 { + return readWorkspaceRoot(this.extensionsOf(component)); + } + + private extensionsOf(component: Component) { + return (component.state._consumer as ConsumerComponent).extensions; + } + + static runtime = MainRuntime; + + static dependencies = [WorkspaceAspect, ComponentAspect, CLIAspect]; + + 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; + } +} + +/** + * 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); 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/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 () => { 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": [