Recently, this aspect was involved in the development of monorepo-related tools in the company, so I did some research on this aspect.

The description of Lerna on lerna’s website is as follows:

A tool for managing JavaScript projects with multiple packages.

Lerna is essentially a tool for managing Monorepo projects, addressing the following issues:

Splitting a large code repository into separate versioned packages is very useful for code sharing. However, some changes can become cumbersome and difficult to track if they span multiple code repositories, and testing across multiple code repositories can quickly become very complex.

To solve these (and many other) problems, some projects split the code repository into packages and store each package in a separate code repository. However, projects such as Babel, React, Angular, Ember, Meteor, Jest, and many others are developed with multiple packages in a code repository.

Today we will mainly explain how Lerna completes the packet sending operation in a Monorepo project.

Lerna sends packets to the design of two more critical instructions are lerna version and LerNA publish instructions.

Due to the lack of space, I will divide into two series to introduce these two instructions respectively. This article will start with Lerna Version. In the next part, I will introduce the working principle of Lerna publish. You will have a pretty clear idea of lerna’s overall package issuing mechanism.

lerna version

According to the official repository, lerna version’s main job is to identify the monorepo package updated since the last tag version, and then prompt the publication of these packages. After the user has made the selection, modify the package version information and commit the relevant changes, then tag and push them to git Remote. Essentially, a Bump version action was performed for packages that were changed. In particular, users can automate the activity. A common scenario is that it can be written in some CI Config to achieve the purpose of automatic dispatch.

At the same time, it also provides some related API, you can go to the warehouse or official documents, not detailed introduction here.

Lerna version itself is an important instruction in LerNA, and many other instructions such as Lerna publish, one of the two primary Commands in LerNA, are based on this instruction. Release an overall schematic of lerna version before starting the implementation.

Lerna version can be used to create the lerna version.

Lerna as a monorepo tool, its own package is also in the form of Monorepo management, so we can directly see the code structure of version:

.├ ── Changelo.md ├─ __tests__ // Test related directory ├─ command.js // ├─ index.js // version To perform logics related functions file ├─ lib // related tool functions │ ├─ __mocks__ │ ├─ create-release. Js │ ├─ Get -current-branch. Js │ ├─ Git-add.js │ ├─ git-commit ├── Git-pusher.js │ ├── Git-tag.js │ ├── Is-anything │ ├ ─ ─ prompt - version. Js │ ├ ─ ─ remote - branch - the exists. Js │ └ ─ ─ the update - lockfile - version. Js └ ─ ─ package. The jsonCopy the code

In the command. Js file, you can see the standard Options provided by lerna version when used in the CLI, such as allow-branch, Xstraw-commits, and ignore-changes Such as options.

The specific execution logic is implemented in the directory index.js.

By the way, the logic for how lerna commands are distributed and executed can be found in the @lerna/command package, the source directory lerna/core/cli/command. Lerna Monorepo some core concepts are under the directory of Core, which will include some content such as the construction of monorepo dependency graph, you can study.

So now let’s look at index.js.

The main output of this file is an instance of the VersionCommand class. The related logic is implemented in the class, which is roughly structured as follows:

class VersionCommand extends Command {
  // Check and integrate some options
  configureProperties() {}
  
  / / initialization
  initialize() {}
  
  // Execute logic
  execute() {}
  
  // The other parts are the utility functions in the class such as getVersionsForUpdates
  // ...
}
Copy the code

Therefore, lerna version is divided into three parts: set properties -> initialize -> execute.

Set Properties (configureProperties)

First, we’ll look at setting the attribute. This is a simple section that checks for passed options (for example, –create-release must be executed with — Xstraw-commits; otherwise, an error is reported). Git options are incorporated into an object called gitOptions. Here is a simple paste some code for reference:

// verify --create-release usage scenario
if (this.createRelease && this.options.conventionalCommits ! = =true) {
  throw new ValidationError("ERELEASE"."To create a release, you must enable --conventional-commits");
}
if (this.createRelease && this.options.changelog === false) {
  throw new ValidationError("ERELEASE"."To create a release, you cannot pass --no-changelog");
}

// Put some git related parameters passed in by the user in gitOptions
this.gitOpts = {
  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
  amend,
  commitHooks,
  granularPathspec,
  signGitCommit,
  signGitTag,
  forceGitTag,
};

// when the user passes --exact, version does not need the ^ prefix
/ / NPM package prefix version related can refer to: https://docs.npmjs.com/misc/config#save-prefix
this.savePrefix = this.options.exact ? "" : "^";
Copy the code

Initialize the initialize ()

In the bump Version process, there will be an initialization process. The initialization process will first check some monorepo child packages and git repository Settings, get the required update package, and then perform the step of confirming the package version information. Some relatively key operations are completed in one step, we can analyze them in detail later:

The general execution logic is as follows:

initialize() {
  // ...
  // 1. Git repository related checks
  if (this.requiresGit) {
  } else {
    // Skip git repository validation
  }
  
  // 2. Obtain the package to be updated
  this.updates = collectUpdates();
  // If the array is empty, execution is stopped
  if (!this.updates.length) {}
  
  // 3. Package-specific lifecycle functions (these functions are set in lerna.json and can be found in lerna documentation)
  this.runPackageLifecycle = createRunner(this.options);
  
  // 4. Get the version to be updated, update the version, make sure the update function is placed in a Tasks array, then execute
  const tasks = [
    () = > this.getVersionsForUpdates(),
    // Versions is made up of getVersionsForUpdates from the previous step
    versions= > this.setUpdatesForVersions(versions),
    () = > this.confirmVersions(),
  ];
  
  // 5. Check the current git local workspace
  if (this.commitAndTag && this.gitOpts.amend ! = =true) {
    // A check function is placed at the top of the queue for the task function
    const check = checkUncommittedOnly ? checkWorkingTree.throwIfUncommitted : checkWorkingTree;
    tasks.unshift(() = > check(this.execOpts));
  } else {}
  
  // 6. Execute tasks
  return pWaterfall(tasks);
}
Copy the code

In fact, looking at this part of the logic, Lerna still has some features waiting to go to TODO, so if you are interested, you can join in a wave of contributions.

Let’s start with git repository related validation. The first thing we need to know is that lerna manages monorepo workflows based on Git and NPM, so as a Lerna Monorepo project, you need to use Git to manage the repository.

RequiresGit is actually a get method of the versionCommand class that returns a Git-related value that exists:

// If one of these parameters is present, it is necessary to verify git related content
get requiresGit() {
    return (
      this.commitAndTag || this.pushToRemote || this.options.allowBranch || this.options.conventionalCommits
    );
  }
Copy the code

The verification logic within requiresGit takes place before the package that needs to be updated is calculated and the version is selected (both steps are done before Initialize (), of course).

Specific inspection logic:

  • Verify whether the local is notcommitcontent
  • Determines whether the current branch is healthy
  • Check whether the current branch is inremoteThere are
  • Check whether the current branch is inlerna.jsonAllow the allowBranchSet of
  • Determine if the current branch commit is behindremote

Git command: git command: git command: git command: git command: git command

function currentBranch(opts) {
  log.silly("currentBranch");

  const branch = childProcess.execSync("git"["rev-parse"."--abbrev-ref"."HEAD"], opts);
  log.verbose("currentBranch", branch);

  return branch;
}
Copy the code

Git rev-parse –abbrev-ref HEAD gets the current branch name.

Other git related judgments are similar to this, so I will not make specific details here. If you have relevant requirements, you can learn the use of related tools and functions here. There are some git commands that are not commonly used in daily life.

Let’s go straight to the logic of getting the update package:

this.updates = collectUpdates(
  this.packageGraph.rawPackageList,
  / / project
  this.packageGraph,
  // Some options for git commands take maxBuffer and CWD
  this.execOpts,
  // Some cli parameters and lerna.json configuration and environment variables etc
  this.options
).filer(node= > {
  // This will skip updates to subpackages that satisfy pkg.json with private: true and pass --no-private
  if (node.pkg.private && this.options.private === false) {
    // Here --no-privte should be the default behavior, which can give LERna a PR
    return false;
  }
  
  // The current package does not have the version parameter
  if(! node.version) {// the version parameter is not set in PKG. Json, but private can be jumped
    if (node.pkg.private) {
    } else {
      / / wrong}}// Screen out the version
  return!!!!! node.version; })Copy the code

Here we will directly look at how collectUpdates gets the package information that needs to be updated. The filter operation after collectUpdates has been annotated. We will not introduce it in detail later, so you can refer to it by yourself.

function collectUpdates(filteredPackages, packageGraph, execOpts, commandOptions) {
   // ...
  // forced is a Set object of the package name to be forced
  const forced = getPackagesForOption(useConventionalGraduate ? conventionalGraduate : forcePublish);
  Monorepo contains a packageList with package name as the key
  const packages =
    filteredPackages.length === packageGraph.size
      ? packageGraph
      : new Map(filteredPackages.map(({ name }) = > [name, packageGraph.get(name)]));
   
  // --since 
      
        lerna version is undefined
      
   let committish = commandOptions.since;
   
  if (hasTags(execOpts)) {
    Git tag -a "v3" -m "xx"
    const { sha, refCount, lastTagName } = describeRef.sync(execOpts, commandOptions.includeMergedTags);

    // The last tagCommit to the current commit is refCount
    if (refCount === "0" && forced.size === 0 && !committish) {
      log.notice(""."Current HEAD is already released, skipping change detection.");
      return [];
    }

    // This is a test version option
    if (commandOptions.canary) {
      committish = `${sha}^..${sha}`;
    } else if(! committish) {// If there is no tag, committish is undefined, and committish is initialized with a commitcommittish = lastTagName; }}// The user used -- straw-commits -- straw-graduate
    if (useConventionalGraduate) {
      // All pre-released packages will be updated to the official package version
      if (forced.has("*")) {
        // --force-publish
        log.info(""."Graduating all prereleased packages");
      } else {
        log.info(""."Graduating prereleased packages"); }}else if(! committish || forced.has(The '*')) {
      // if a --force-publish or tag is not found (lerna was not published before), then all packages need to be updated
      log.info(""."Assuming all packages changed");
      
      // The collectPackages will have three parameters and an isCandidate to add filters to determine which packages need to be updated
      // Where isCandidate is not passed means that passed packages are updated
      return collectPackges(packages, {
        onInclude: name= > log.verbose("updated", name),
        // This excludeDependents option is determined by the --no-private parameter to exclude private: true packages
        excludeDependents,
      })
    }
    
  // The following is a normal collection situation
  // ...
  return collectPackages(packages, {
    // isForced, needsBump
    // hasDiff(package with changes, where ignoreChanges takes effect in waves)
    isCandidate: (node, name) = > isForced(node, name) || needsBump(node) || hasDiff(node),
    onInclude: name= > log.verbose("updated", name),
    excludeDependents,
  });
}
Copy the code

According to the above code level, we can see clearly how collectUpdates collect the package that needs to be updated. First, we will get some packages under the current Monorepo project from core. Then get an updates array of the packages that need to be updated under the current MonorePO project based on the tag information of some options (such as –force-publish and — Conventional — commit) and the project’s own commit. Git describe tags are annotated tags. Git describe tags are annotated tags. Git describe tags are annotated tags and git describe tags are annotated tags.

This updates data will be followed as a series of operations for the Bump Version.

After getting the Updates array, the initialization process comes to executing the runLifeCycle function, which is used to execute some of the user-set lifecycle functions in lerna.json, which I won’t go into too much detail here. For details about how to write the execution function, see the @lerna/run-lifecycle library.

Then there are the steps of the initial execution, where a Tasks queue is built for execution

// Some of the version functions here do their work based on the updates array obtained earlier
const tasks = [
  // Get the version to be updated
  () = > this.getVersionsForUpdates(),
  // Set the versions value. This is a reduce procedure, which actually returns the versions value from the previous function
  versions= > this.setUpdatesForVersions(versions),
  // Confirm the update
  () = > this.confirmVersions(),
];

// Check whether the working Tree is working properly by inserting the tasks function at the top of the git task queue
// ...

// Execute the task queue method according to reduce
return pWaterfall(tasks);

Copy the code

This is the last step of initialization, and we’ll focus on the execution details of the three methods in the Tasks queue.

The getVersionsForupdates process here is the prompt process, resulting in a Map object with the package name key in the updates array and the version selected by the user as value.

In particular, the description listed here is a description of general user activity. In particular, a particular description is that A particular activity should be characterized in a particular way. It is recommended to read this in conjunction with some of the API descriptions in the Lerna version documentation).

if (repoVersion) {
  The user passed the semver bump parameter in lerna version []. The prompt process can be skipped directly, and the corresponding version can be bumped directly
  // This corresponds to the official released version
  predicate = makeGlobalVersionPredicate(repoVersion);
} else if (increment && independentVersions) {
  // Increament differs from repoVersion in that the corresponding package of an informal version is in the prereleased stage
  // The process is similar to the above, which corresponds to all indepent packages (i.e. the user set indepent in lerna.json).
  predicate = node= > semver.inc(node.version, increment, resolvePrereleaseId(node.prereleaseId));
} else if (increment) {
  // This corresponds to an update of the prereleased version of fixed
} else if (conventionalCommits) {
  // If the description is -- Straw-commits, prompt is not needed
  return this.recommendVersions(resolvePrereleaseId);
} else if (independentVersions) {
  // This is the process of doing prompt on a normal Indepent package
  predicate = makePromptVersion(resolvePrereleaseId);
} else {
  // Fixed package does prompt
  // ...
}

// The predicate above is a function that returns newversion
// put getVersion (the newVersion it returns) and then use the reduceVersions method to generate the Map objects I mentioned above
// reduceVersion is also a typical Reduce application (i.e. the results of the previous step can be used for the next function execution)
return Promise.resolve(predicate).then(getVersion= > this.reduceVersions(getVersion));
Copy the code

After we get to getVersionsForUpdates(), we move to the setUpdatesForVersions process, which is relatively easy. We take the previous versions Map and use it to do a pre-processing. This step will get you an update sions (essentially the versions you got last time) and a packagesToVersion(essentially updates), which is a bit of a consolidation.

setUpdatesForVersions(versions) {
    if (this.project.isIndependent() || versions.size === this.packageGraph.size) {
      // Only some fixed versions need to be checked
      this.updatesVersions = versions;
    } else {
      let hasBreakingChange;
      for (const [name, bump] of versions) {
        BreakChange is much more intuitive than what I have described here. The code is below
        hasBreakingChange = hasBreakingChange || isBreakingChange(this.packageGraph.get(name).version, bump);
      }
      if (hasBreakingChange) {
        this.updates = Array.from(this.packageGraph.values());
        // Filter out packets with private set to true
        if (this.options.private === false) {
          this.updates = this.updates.filter(node= >! node.pkg.private); }this.updatesVersions = new Map(this.updates.map(node= > [node.name, this.globalVersion]));
      } else {
        this.updatesVersions = versions; }}this.packagesToVersion = this.updates.map(node= > node.pkg);
  }
Copy the code

The above method for judging breackChange is:

// releaseType can determine which part of the package has changed
const releaseType = semver.diff(currentVersion, nextVersion);
let breaking;
if (releaseType === "major") {
  // self-evidently
  breaking = true;
} else if (releaseType === "minor") {
  // 0.1.9 => 0.2.0 is breaking
  // Lt is less than
  breaking = semver.lt(currentVersion, "1.0.0");
} else if (releaseType === "patch") {
  // 0.0.1 => 0.0.2 is breaking(?)
  breaking = semver.lt(currentVersion, "0.1.0 from");
} else {
  // versions are equal, or any prerelease
  breaking = false;
}
Copy the code

The final confirmVersion step actually displays the packagesToVersion value generated by setUpdatesForVersions in the previous step, allows the user to determine whether the previous selection is correct, and then returns a bool. Then, at execution time, you can do some updates based on the initialization result.

So when this whole initialization process is over, it’s a little fun to actually initialize a lot of this stuff and put it in execute. Since the initialization has done most of the lerna version functionality, the final execution is essentially just an update to the version in the PKG.

Execution (execute)

Please go back to the beginning of the article to see the overall implementation process of Lerna Version. By now, we have completed the version update operation and got some relevant parameters. The last few steps are to get the package version in the PKG you want to change, then commit the change, tag it and push it to git Remote.

This is where the execution takes place.

As with Initialize (), it initializes an array of tasks, puts the associated operation functions into the task, and then runs them using p-reduce.

 execute(){
   // Put the updated package version of the function
    const tasks = [() = > this.updatePackageVersions()];
    // You can skip the tag and commit processes by setting --no-git-tag-version
    // This commitAndTag defaults to true
    if (this.commitAndTag) {
      tasks.push(() = > this.commitAndTagUpdates());
    } else {
      this.logger.info("execute"."Skipping git tag/commit");
    }

   PushToRemote is affected by both the commitAndTag and ammend parameters
   // The user can make the lerna version not push at the end by simply --amend
    if (this.pushToRemote) {
      tasks.push(() = > this.gitPushToRemote());
    } else {
      this.logger.info("execute"."Skipping git push");
    }

   // createRelease is used to set releases for a particular platform, in conjunction with -- Straw-commits
   // This will not be explained in detail
    if (this.createRelease) {
      this.logger.info("execute"."Creating releases...");
      tasks.push(() = >
        createRelease(
          this.options.createRelease,
          { tags: this.tags, releaseNotes: this.releaseNotes },
          { gitRemote: this.options.gitRemote, execOpts: this.execOpts }
        )
      );
    } else {
      this.logger.info("execute"."Skipping releases");
    }

   // The pWaterfall method encapsulates the p-reduce library function
    return pWaterfall(tasks).then(() = > {
      The composed is used to indicate whether lerna version or LERna publish is currently executed
      if (!this.composed) {
        // Lerna version ends this process
        this.logger.success("version"."finished");
      }
      // If lerna publish, return these parameters
      return {
        updates: this.updates,
        updatesVersions: this.updatesVersions,
      };
    });
 }
Copy the code

The normal logic is to update the package version -> generate commit and tag -> commit to remote.

The updatePackageVersions method will update the package, change the version in the file, and add the update Git add to the deferred area. It will also help us generate or update CHANGELOG in this step. This step uses a changeFiles to record the path of the modified file.

The code structure looks like this:

updatePackageVersions() {
  const { conventionalCommits, changelogPreset, changelog = true } = this.options;
  const independentVersions = this.project.isIndependent();
  const rootPath = this.project.manifest.location;
  // Record the modified file
  const changedFiles = new Set(a);// Here are a series of chained calls and asynchronous methods are used in the promise, the author even in the source code here teasing to use async/await...
  let chain = Promise.resolve();
  
  // The action is also a reduce execution process, i.e. the function in the previous step will give the result to the following
  const actions = [
    / /.. Inside is a bunch of functions related to PKG updates
    // There is a function that updates the version in this step
    pkg= > {
        // Set the new version to
       pkg.set("version".this.updatesVersions.get(pkg.name));
       
      // PKG-related dependencies (also updated with updates)
      // This is also a dependency graph from Core, and then you can get a list of the internal lerna libraries that PKG depends on, and those internal lerna libraries need to be updated as well
      for (const [depName, resolved] of this.packageGraph.get(pkg.name).localDependencies) {
          const depVersion = this.updatesVersions.get(depName);
          if(depVersion && resolved.type ! = ="directory") {
            // don't overwrite local file: specifiers, they only change during publish
            pkg.updateLocalDependency(resolved, depVersion, this.savePrefix); }}// Update the pkg-lock.json version and reflect the changes to the PKG file (pkg.serialize())
      // Then the changes are recorded in changeFiles
       return Promise.all([updateLockfileVersion(pkg), pkg.serialize()]).then(/ /...). ;
    }
    // ..];// If there are -- Straw-commits, CHANGELOG will be generated automatically
  // The following updateChangelog method is also visible
  if (conventionalCommits && changelog) {
      // we can now generate the Changelog, based on the
      // the updated version that we're about to release.
      const type = independentVersions ? "independent" : "fixed";

      actions.push(pkg= >
        ConventionalCommitUtilities.updateChangelog(pkg, type, {
          changelogPreset,
          rootPath,
          tagPrefix: this.tagPrefix,
        }).then(({ logPath, newEntry }) = > {
          // commit the updated changelog
          changedFiles.add(logPath);
          // Added Notes for release
          if (independentVersions) {
            this.releaseNotes.push({
              name: pkg.name,
              notes: newEntry,
            });
          }
          returnpkg; })); }// pPipe is a encapsulated method that executes asynchronous functions in reduce order
  // The result of the last step will be returned by executing the previous actions
  const mapUpdate = pPipe(actions);
  
  // Execute the packages that need to be updated and the pathfiles that need to be updated in topology order
  chain = chain.then(() = >
      runTopologically(this.packagesToVersion, mapUpdate, {
        concurrency: this.concurrency,
        rejectCycles: this.options.rejectCycles,
      })
   );
  
  // Fixed type monorepo project update will have a lerna.json modification
  // Because of the revision, there are "straw-commits", which are the same as above
 if(! independentVersions) {}// The default value is true. Git changes are added to the cache
 if (this.commitAndTag) {
     chain = chain.then(() = > gitAdd(Array.from(changedFiles), this.gitOpts, this.execOpts));
 }
      
 return chain;
}
Copy the code

Then let’s look at the process of generating a COMMIT. This step is relatively easy. Add the previous commit to the cache and tag it. The logic is in the commitAndTagUpdates method. This step is simple and can be looked at directly.

commitAndTagUpdates() {
    let chain = Promise.resolve();

  Git add. and git tag. Commit messages can be set by the user or generated automatically by Lerna
    if (this.project.isIndependent()) {
      chain = chain.then(() = > this.gitCommitAndTagVersionForUpdates());
    } else {
      chain = chain.then(() = > this.gitCommitAndTagVersion());
    }

    chain = chain.then(tags= > {
      this.tags = tags;
    });
    // ...

    return chain;
}
Copy the code

The last step is to push the commit and tag to remote. This step is easier. Lerna wraps a Git push method and pushes it to the remote of the current branch.

gitPushToRemote() {
  this.logger.info("git"."Pushing tags...");

  // gitPush simply encapsulates git push
  return gitPush(this.gitRemote, this.currentBranch, this.execOpts);
}
Copy the code

conclusion

This article mainly introduces the working principle of Lerna version from the perspective of source code. Lerna version plays a very important role in the operation of sending packets using LerNA. If lerna publish is used alone, In general, the bump version operation will be performed first by Lerna version and then the package will be sent. Therefore, Lerna version is a basis for LerNA to send monorepo packages.

The next article will show you what Lerna Publish does and how it does it.