Repository: actions/toolkit
Pull request #268 (2019-12-20 17:54:06)

glob
New module for globbing

joshmgross: Should a section be added to the main README for this new package? https://github.com/actions/toolkit/blob/master/README.md#packages
ericsciple: yes it's tracked as a consequence in the ADR. havent done it yet though, still working on unit tests. I might do docs as a separate PR

Patch:
diff --git a/packages/glob/package-lock.json b/packages/glob/package-lock.json
new file mode 100644
index 0000000..3eb24c1
--- /dev/null
+++ b/packages/glob/package-lock.json
@@ -0,0 +1,35 @@
+{
+	"name": "@actions/glob",
+	"version": "0.1.0",
+	"lockfileVersion": 1,
+	"requires": true,
+	"dependencies": {
+		"balanced-match": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+			"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+		},
+		"brace-expansion": {
+			"version": "1.1.11",
+			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+			"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+			"requires": {
+				"balanced-match": "^1.0.0",
+				"concat-map": "0.0.1"
+			}
+		},
+		"concat-map": {
+			"version": "0.0.1",
+			"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+			"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+		},
+		"minimatch": {
+			"version": "3.0.4",
+			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+			"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+			"requires": {
+				"brace-expansion": "^1.1.7"
+			}
+		}
+	}
+}
diff --git a/packages/glob/package.json b/packages/glob/package.json
new file mode 100644
index 0000000..067d59c
--- /dev/null
+++ b/packages/glob/package.json
@@ -0,0 +1,41 @@
+{
+  "name": "@actions/glob",
+  "version": "0.1.0",
+  "preview": true,
+  "description": "Actions glob lib",
+  "keywords": [
+    "github",
+    "actions",
+    "glob"
+  ],
+  "homepage": "https://github.com/actions/toolkit/tree/master/packages/glob",
+  "license": "MIT",
+  "main": "lib/glob.js",
+  "types": "lib/glob.d.ts",
+  "directories": {
+    "lib": "lib",
+    "test": "__tests__"
+  },
+  "files": [
+    "lib"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/actions/toolkit.git",
+    "directory": "packages/glob"
+  },
+  "scripts": {
+    "test": "echo \"Error: run tests from root\" && exit 1",
+    "tsc": "tsc"
+  },
+  "bugs": {
+    "url": "https://github.com/actions/toolkit/issues"
+  },
+  "dependencies": {
+    "@actions/core": "^1.2.0",
+    "minimatch": "^3.0.4"
+  }
+}
diff --git a/packages/glob/src/glob.ts b/packages/glob/src/glob.ts
new file mode 100644
index 0000000..861e1e8
--- /dev/null
+++ b/packages/glob/src/glob.ts
@@ -0,0 +1,183 @@
+import * as core from '@actions/core'
+import * as fs from 'fs'
+import * as path from 'path'
+import * as patternHelper from './internal-pattern-helper'
+import {IGlobOptions} from './internal-glob-options'
+import {MatchKind} from './internal-match-kind'
+import {Pattern} from './internal-pattern'
+import {SearchState} from './internal-search-state'
+
+export {IGlobOptions}
+
+/**
+ * Returns files and directories matching the specified glob pattern.
+ *
+ * Order of the results is not guaranteed.
+ */
+export async function glob(
+  pattern: string,
+  options?: IGlobOptions
+): Promise<string[]> {
+  const result: string[] = []
+  for await (const itemPath of globGenerator(pattern, options)) {
+    result.push(itemPath)
+  }
+  return result
+}
+
+/**
+ * Returns files and directories matching the specified glob pattern.
+ *
+ * Order of the results is not guaranteed.
+ */
+export async function* globGenerator(
+  pattern: string,
+  options?: IGlobOptions
+): AsyncGenerator<string, void> {
+  // Set defaults options
+  options = patternHelper.getOptions(options)
+
+  // Parse patterns
+  const patterns: Pattern[] = patternHelper.parse([pattern], options)
+
+  // Push the search paths
+  const stack: SearchState[] = []
+  for (const searchPath of patternHelper.getSearchPaths(patterns)) {
+    core.debug(`Search path '${searchPath}'`)
+
+    // Exists?
+    try {
+      // Intentionally using lstat. Detection for broken symlink
+      // will be performed later (if following symlinks).
+      await fs.promises.lstat(searchPath)
+    } catch (err) {
+      if (err.code === 'ENOENT') {
+        continue
+      }
+      throw err
+    }
+
+    stack.unshift(new SearchState(searchPath, 1))
+  }
+
+  // Search
+  const traversalChain: string[] = [] // used to detect cycles
+  while (stack.length) {
+    // Pop
+    const item = stack.pop() as SearchState
+
+    // Match?
+    const match = patternHelper.match(patterns, item.path)
+    const partialMatch =
+      !!match || patternHelper.partialMatch(patterns, item.path)
+    if (!match && !partialMatch) {
+      continue
+    }
+
+    // Stat
+    const stats: fs.Stats | undefined = await stat(
+      item,
+      options,
+      traversalChain
+    )
+
+    // Broken symlink, or symlink cycle detected, or no longer exists
+    if (!stats) {
+      continue
+    }
+
+    // Directory
+    if (stats.isDirectory()) {
+      // Matched
+      if (match & MatchKind.Directory) {
+        yield item.path
+      }
+      // Descend?
+      else if (!partialMatch) {
+        continue
+      }
+
+      // Push the child items in reverse
+      const childLevel = item.level + 1
+      const childItems = (await fs.promises.readdir(item.path)).map(
+        x => new SearchState(path.join(item.path, x), childLevel)
+      )
+      stack.push(...childItems.reverse())
+    }
+    // File
+    else if (match & MatchKind.File) {
+      yield item.path
+    }
+  }
+}
+
+/**
+ * Returns the search path preceeding the first segment that contains a pattern.
+ *
+ * For example, '/foo/bar*' returns '/foo'.
+ */
+export function getSearchPath(pattern: string): string {
+  const patterns: Pattern[] = patternHelper.parse(
+    [pattern],
+    patternHelper.getOptions()
+  )
+  const searchPaths: string[] = patternHelper.getSearchPaths(patterns)
+  return searchPaths.length > 0 ? searchPaths[0] : ''
+}
+
+async function stat(
+  item: SearchState,
+  options: IGlobOptions,
+  traversalChain: string[]
+): Promise<fs.Stats | undefined> {
+  // Note:
+  // `stat` returns info about the target of a symlink (or symlink chain)
+  // `lstat` returns info about a symlink itself
+  let stats: fs.Stats
+  if (options.followSymbolicLinks) {
+    try {
+      // Use `stat` (following symlinks)
+      stats = await fs.promises.stat(item.path)
+    } catch (err) {
+      if (err.code === 'ENOENT') {
+        if (options.omitBrokenSymbolicLinks) {
+          core.debug(`Broken symlink '${item.path}'`)
+          return undefined
+        }
+
+        throw new Error(
+          `No information found for the path '${item.path}'. This may indicate a broken symbolic link.`
+        )
+      }
+
+      throw err
+    }
+  } else {
+    // Use `lstat` (not following symlinks)
+    stats = await fs.promises.lstat(item.path)
+  }
+
+  // Note, isDirectory() returns false for the lstat of a symlink
+  if (stats.isDirectory() && options.followSymbolicLinks) {
+    // Get the realpath
+    const realPath: string = await fs.promises.realpath(item.path)
+
+    // Fixup the traversal chain to match the item level
+    while (traversalChain.length >= item.level) {
+      traversalChain.pop()
+    }
+
+    // Test for a cycle
+    if (traversalChain.some((x: string) => x === realPath)) {
+      core.debug(
+        `Symlink cycle detected for path '${item.path}' and realpath '${realPath}'`
+      )
+      return undefined
+    }
+
+    // Update the traversal chain
+    traversalChain.push(realPath)
+  }
+
+  return stats
+}
diff --git a/packages/glob/src/internal-glob-options.ts b/packages/glob/src/internal-glob-options.ts
new file mode 100644
index 0000000..8e42745
--- /dev/null
+++ b/packages/glob/src/internal-glob-options.ts
@@ -0,0 +1,28 @@
+export interface IGlobOptions {
+  /**
+   * Indicates whether to follow symbolic links. Generally should be true
+   * unless deleting files.
+   *
+   * @default true
+   */
+  followSymbolicLinks?: boolean
+
+  /**
+   * Indicates whether directories that match a glob pattern, should implicitly
+   * cause all descendant paths to be matched.
+   *
+   * For example, given the directory `my-dir`, the following glob patterns
+   * would produce the same results: `my-dir/**`, `my-dir/`, `my-dir`
+   *
+   * @default true
+   */
+  implicitDescendants?: boolean
+
+  /**
+   * Indicates whether broken symbolic should be ignored and omitted from the
+   * result set. Otherwise an error will be thrown.
+   *
+   * @default true
+   */
+  omitBrokenSymbolicLinks?: boolean
+}
diff --git a/packages/glob/src/internal-match-kind.ts b/packages/glob/src/internal-match-kind.ts
new file mode 100644
index 0000000..75802da
--- /dev/null
+++ b/packages/glob/src/internal-match-kind.ts
@@ -0,0 +1,16 @@
+/**
+ * Indicates whether a pattern matches a path
+ */
+export enum MatchKind {
+  /** Not matched */
+  None = 0,
+
+  /** Matched if the path is a directory */
+  Directory = 1,
+
+  /** Matched if the path is a regular file */
+  File = 2,
+
+  /** Matched */
+  All = Directory | File
+}
diff --git a/packages/glob/src/internal-path-helper.ts b/packages/glob/src/internal-path-helper.ts
new file mode 100644
index 0000000..7d9d0ef
--- /dev/null
+++ b/packages/glob/src/internal-path-helper.ts
@@ -0,0 +1,206 @@
+import * as assert from 'assert'
+import * as path from 'path'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+/**
+ * Similar to path.dirname except normalizes the path separators and slightly better handling for Windows UNC paths.
+ *
+ * For example, on Linux/macOS:
+ * - `/               => /`
+ * - `/hello          => /`
+ *
+ * For example, on Windows:
+ * - `C:\             => C:\`
+ * - `C:\hello        => C:\`
+ * - `C:              => C:`
+ * - `C:hello         => C:`
+ * - `\               => \`
+ * - `\hello          => \`
+ * - `\\hello         => \\hello`
+ * - `\\hello\world   => \\hello\world`
+ */
+export function dirname(p: string): string {
+  // Normalize slashes and trim unnecessary trailing slash
+  p = safeTrimTrailingSeparator(p)
+
+  // Windows UNC root, e.g. \\hello or \\hello\world
+  if (IS_WINDOWS && /^\\\\[^\\]+(\\[^\\]+)?$/.test(p)) {
+    return p
+  }
+
+  // Get dirname
+  let result = path.dirname(p)
+
+  // Trim trailing slash for Windows UNC root, e.g. \\hello\world\
+  if (IS_WINDOWS && /^\\\\[^\\]+\\[^\\]+\\$/.test(result)) {
+    result = safeTrimTrailingSeparator(result)
+  }
+
+  return result
+}
+
+/**
+ * Roots the path if not already rooted. On Windows, relative roots like `\`
+ * or `C:` are expanded based on the current working directory.
+ */
+export function ensureAbsoluteRoot(root: string, itemPath: string): string {
+  assert(root, `ensureAbsoluteRoot parameter 'root' must not be empty`)
+  assert(itemPath, `ensureAbsoluteRoot parameter 'itemPath' must not be empty`)
+
+  // Already rooted
+  if (hasAbsoluteRoot(itemPath)) {
+    return itemPath
+  }
+
+  // Windows
+  if (IS_WINDOWS) {
+    // Check for itemPath like C: or C:foo
+    if (itemPath.match(/^[A-Z]:[^\\/]|^[A-Z]:$/i)) {
+      let cwd = process.cwd()
+      assert(
+        cwd.match(/^[A-Z]:\\/i),
+        `Expected current directory to start with an absolute drive root. Actual '${cwd}'`
+      )
+
+      // Drive letter matches cwd? Expand to cwd
+      if (itemPath[0].toUpperCase() === cwd[0].toUpperCase()) {
+        // Drive only, e.g. C:
+        if (itemPath.length === 2) {
+          // Preserve specified drive letter case (upper or lower)
+          return `${itemPath[0]}:\\${cwd.substr(3)}`
+        }
+        // Drive + path, e.g. C:foo
+        else {
+          if (!cwd.endsWith('\\')) {
+            cwd += '\\'
+          }
+          // Preserve specified drive letter case (upper or lower)
+          return `${itemPath[0]}:\\${cwd.substr(3)}${itemPath.substr(2)}`
+        }
+      }
+      // Different drive
+      else {
+        return `${itemPath[0]}:\\${itemPath.substr(2)}`
+      }
+    }
+    // Check for itemPath like \ or \foo
+    else if (normalizeSeparators(itemPath).match(/^\\$|^\\[^\\]/)) {
+      const cwd = process.cwd()
+      assert(
+        cwd.match(/^[A-Z]:\\/i),
+        `Expected current directory to start with an absolute drive root. Actual '${cwd}'`
+      )
+
+      return `${cwd[0]}:\\${itemPath.substr(1)}`
+    }
+  }
+
+  assert(
+    hasAbsoluteRoot(root),
+    `ensureAbsoluteRoot parameter 'root' must have an absolute root`
+  )
+
+  // Otherwise ensure root ends with a separator
+  if (root.endsWith('/') || (IS_WINDOWS && root.endsWith('\\'))) {
+    // Intentionally empty
+  } else {
+    // Append separator
+    root += path.sep
+  }
+
+  return root + itemPath
+}
+
+/**
+ * On Linux/macOS, true if path starts with `/`. On Windows, true for paths like:
+ * `\\hello\share` and `C:\hello` (and using alternate separator).
+ */
+export function hasAbsoluteRoot(itemPath: string): boolean {
+  assert(itemPath, `hasAbsoluteRoot parameter 'itemPath' must not be empty`)
+
+  // Normalize separators
+  itemPath = normalizeSeparators(itemPath)
+
+  // Windows
+  if (IS_WINDOWS) {
+    // E.g. \\hello\share or C:\hello
+    return itemPath.startsWith('\\\\') || /^[A-Z]:\\/i.test(itemPath)
+  }
+
+  // E.g. /hello
+  return itemPath.startsWith('/')
+}
+
+/**
+ * On Linux/macOS, true if path starts with `/`. On Windows, true for paths like:
+ * `\`, `\hello`, `\\hello\share`, `C:`, and `C:\hello` (and using alternate separator).
+ */
+export function hasRoot(itemPath: string): boolean {
+  assert(itemPath, `isRooted parameter 'itemPath' must not be empty`)
+
+  // Normalize separators
+  itemPath = normalizeSeparators(itemPath)
+
+  // Windows
+  if (IS_WINDOWS) {
+    // E.g. \ or \hello or \\hello
+    // E.g. C: or C:\hello
+    return itemPath.startsWith('\\') || /^[A-Z]:/i.test(itemPath)
+  }
+
+  // E.g. /hello
+  return itemPath.startsWith('/')
+}
+
+/**
+ * Removes redundant slashes and converts `/` to `\` on Windows
+ */
+export function normalizeSeparators(p: string): string {
+  p = p || ''
+
+  // Windows
+  if (IS_WINDOWS) {
+    // Convert slashes on Windows
+    p = p.replace(/\//g, '\\')
+
+    // Remove redundant slashes
+    const isUnc = /^\\\\+[^\\]/.test(p) // e.g. \\hello
+    return (isUnc ? '\\' : '') + p.replace(/\\\\+/g, '\\') // preserve leading \\ for UNC
+  }
+
+  // Remove redundant slashes
+  return p.replace(/\/\/+/g, '/')
+}
+
+/**
+ * Normalizes the path separators and trims the trailing separator (when safe).
+ * For example, `/foo/ => /foo` but `/ => /`
+ */
+export function safeTrimTrailingSeparator(p: string): string {
+  // Short-circuit if empty
+  if (!p) {
+    return ''
+  }
+
+  // Normalize separators
+  p = normalizeSeparators(p)
+
+  // No trailing slash
+  if (!p.endsWith(path.sep)) {
+    return p
+  }
+
+  // Check '/' on Linux/macOS and '\' on Windows
+  if (p === path.sep) {
+    return p
+  }
+
+  // On Windows check if drive root. E.g. C:\
+  if (IS_WINDOWS && /^[A-Z]:\\$/i.test(p)) {
+    return p
+  }
+
+  // Otherwise trim trailing slash
+  return p.substr(0, p.length - 1)
+}
diff --git a/packages/glob/src/internal-path.ts b/packages/glob/src/internal-path.ts
new file mode 100644
index 0000000..789cb11
--- /dev/null
+++ b/packages/glob/src/internal-path.ts
@@ -0,0 +1,113 @@
+import * as assert from 'assert'
+import * as path from 'path'
+import * as pathHelper from './internal-path-helper'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+/**
+ * Helper class for parsing paths into segments
+ */
+export class Path {
+  segments: string[] = []
+
+  /**
+   * Constructs a Path
+   * @param itemPath Path or array of segments
+   */
+  constructor(itemPath: string | string[]) {
+    // String
+    if (typeof itemPath === 'string') {
+      assert(itemPath, `Parameter 'itemPath' must not be empty`)
+
+      // Normalize slashes and trim unnecessary trailing slash
+      itemPath = pathHelper.safeTrimTrailingSeparator(itemPath)
+
+      // Not rooted
+      if (!pathHelper.hasRoot(itemPath)) {
+        this.segments = itemPath.split(path.sep)
+      }
+      // Rooted
+      else {
+        // Add all segments, while not at the root
+        let remaining = itemPath
+        let dir = pathHelper.dirname(remaining)
+        while (dir !== remaining) {
+          // Add the segment
+          const basename = path.basename(remaining)
+          this.segments.unshift(basename)
+
+          // Truncate the last segment
+          remaining = dir
+          dir = pathHelper.dirname(remaining)
+        }
+
+        // Remainder is the root
+        this.segments.unshift(remaining)
+      }
+    }
+    // Array
+    else {
+      // Must not be empty
+      assert(
+        itemPath.length > 0,
+        `Parameter 'itemPath' must not be an empty array`
+      )
+
+      // Each segment
+      for (let i = 0; i < itemPath.length; i++) {
+        let segment = itemPath[i]
+
+        // Must not be empty
+        assert(
+          segment,
+          `Parameter 'itemPath' must not contain any empty segments`
+        )
+
+        // Normalize slashes
+        segment = pathHelper.normalizeSeparators(itemPath[i])
+
+        // Root segment
+        if (i === 0 && pathHelper.hasRoot(segment)) {
+          segment = pathHelper.safeTrimTrailingSeparator(segment)
+          assert(
+            segment === pathHelper.dirname(segment),
+            `Parameter 'itemPath' root segment contains information for multiple segments`
+          )
+          this.segments.push(segment)
+        }
+        // All other segments
+        else {
+          // Must not contain slash
+          assert(
+            !segment.includes(path.sep),
+            `Parameter 'itemPath' contains unexpected path separators`
+          )
+          this.segments.push(segment)
+        }
+      }
+    }
+  }
+
+  /**
+   * Converts the path to it's string representation
+   */
+  toString(): string {
+    // First segment
+    let result = this.segments[0]
+
+    // All others
+    let skipSlash =
+      result.endsWith(path.sep) || (IS_WINDOWS && /^[A-Z]:$/i.test(result))
+    for (let i = 1; i < this.segments.length; i++) {
+      if (skipSlash) {
+        skipSlash = false
+      } else {
+        result += path.sep
+      }
+
+      result += this.segments[i]
+    }
+
+    return result
+  }
+}
diff --git a/packages/glob/src/internal-pattern-helper.ts b/packages/glob/src/internal-pattern-helper.ts
new file mode 100644
index 0000000..9ca56b1
--- /dev/null
+++ b/packages/glob/src/internal-pattern-helper.ts
@@ -0,0 +1,143 @@
+import * as core from '@actions/core'
+import * as pathHelper from './internal-path-helper'
+import {IGlobOptions} from './internal-glob-options'
+import {MatchKind} from './internal-match-kind'
+import {Pattern} from './internal-pattern'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+/**
+ * Returns a copy with defaults filled in
+ */
+export function getOptions(copy?: IGlobOptions): IGlobOptions {
+  const result: IGlobOptions = {
+    followSymbolicLinks: true,
+    implicitDescendants: true,
+    omitBrokenSymbolicLinks: true
+  }
+
+  if (copy) {
+    if (typeof copy.followSymbolicLinks === 'boolean') {
+      result.followSymbolicLinks = copy.followSymbolicLinks
+      core.debug(`followSymbolicLinks '${result.followSymbolicLinks}'`)
+    }
+
+    if (typeof copy.implicitDescendants === 'boolean') {
+      result.implicitDescendants = copy.implicitDescendants
+      core.debug(`implicitDescendants '${result.implicitDescendants}'`)
+    }
+
+    if (typeof copy.omitBrokenSymbolicLinks === 'boolean') {
+      result.omitBrokenSymbolicLinks = copy.omitBrokenSymbolicLinks
+      core.debug(`omitBrokenSymbolicLinks '${result.omitBrokenSymbolicLinks}'`)
+    }
+  }
+
+  return result
+}
+
+/**
+ * Given an array of patterns, returns an array of paths to search.
+ * Duplicates and paths under other included paths are filtered out.
+ */
+export function getSearchPaths(patterns: Pattern[]): string[] {
+  // Ignore negate patterns
+  patterns = patterns.filter(x => !x.negate)
+
+  // Create a map of all search paths
+  const searchPathMap: {[key: string]: string} = {}
+  for (const pattern of patterns) {
+    const key = IS_WINDOWS
+      ? pattern.searchPath.toUpperCase()
+      : pattern.searchPath
+    searchPathMap[key] = 'candidate'
+  }
+
+  const result: string[] = []
+
+  for (const pattern of patterns) {
+    // Check if already included
+    const key = IS_WINDOWS
+      ? pattern.searchPath.toUpperCase()
+      : pattern.searchPath
+    if (searchPathMap[key] === 'included') {
+      continue
+    }
+
+    // Check for an ancestor search path
+    let foundAncestor = false
+    let tempKey = key
+    let parent = pathHelper.dirname(tempKey)
+    while (parent !== tempKey) {
+      if (searchPathMap[parent]) {
+        foundAncestor = true
+        break
+      }
+
+      tempKey = parent
+      parent = pathHelper.dirname(tempKey)
+    }
+
+    // Include the search pattern in the result
+    if (!foundAncestor) {
+      result.push(pattern.searchPath)
+      searchPathMap[key] = 'included'
+    }
+  }
+
+  return result
+}
+
+/**
+ * Matches the patterns against the path
+ */
+export function match(patterns: Pattern[], itemPath: string): MatchKind {
+  let result: MatchKind = MatchKind.None
+
+  for (const pattern of patterns) {
+    if (pattern.negate) {
+      result &= ~pattern.match(itemPath)
+    } else {
+      result |= pattern.match(itemPath)
+    }
+  }
+
+  return result
+}
+
+/**
+ * Parses the pattern strings into Pattern objects
+ */
+export function parse(patterns: string[], options: IGlobOptions): Pattern[] {
+  const result: Pattern[] = []
+
+  for (const patternString of patterns.map(x => x.trim())) {
+    // Skip empty or comment
+    if (!patternString || patternString.startsWith('#')) {
+      continue
+    }
+
+    // Push
+    const pattern = new Pattern(patternString)
+    result.push(pattern)
+
+    // Implicit descendants?
+    if (
+      options.implicitDescendants &&
+      (pattern.trailingSeparator ||
+        pattern.segments[pattern.segments.length - 1] !== '**')
+    ) {
+      // Push
+      result.push(new Pattern(pattern.negate, pattern.segments.concat('**')))
+    }
+  }
+
+  return result
+}
+
+/**
+ * Checks whether to descend further into the directory
+ */
+export function partialMatch(patterns: Pattern[], itemPath: string): boolean {
+  return patterns.some(x => !x.negate && x.partialMatch(itemPath))
+}
diff --git a/packages/glob/src/internal-pattern.ts b/packages/glob/src/internal-pattern.ts
new file mode 100644
index 0000000..2735ca7
--- /dev/null
+++ b/packages/glob/src/internal-pattern.ts
@@ -0,0 +1,321 @@
+import * as assert from 'assert'
+import * as os from 'os'
+import * as path from 'path'
+import * as pathHelper from './internal-path-helper'
+import {Minimatch, IMinimatch, IOptions as IMinimatchOptions} from 'minimatch'
+import {MatchKind} from './internal-match-kind'
+import {Path} from './internal-path'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+export class Pattern {
+  /**
+   * Indicates whether matches should be excluded from the result set
+   */
+  readonly negate: boolean = false
+
+  /**
+   * The directory to search. The literal path prior to the first glob segment.
+   */
+  readonly searchPath: string
+
+  /**
+   * The path/pattern segments. Note, only the first segment (the root directory)
+   * may contain a directory separator charactor. Use the trailingSeparator field
+   * to determine whether the pattern ended with a trailing slash.
+   */
+  readonly segments: string[]
+
+  /**
+   * Indicates the pattern should only match directories, not regular files.
+   */
+  readonly trailingSeparator: boolean
+
+  /**
+   * The Minimatch object used for matching
+   */
+  private readonly minimatch: IMinimatch
+
+  /**
+   * Used to workaround a limitation with Minimatch when determining a partial
+   * match and the path is a root directory. For example, when the pattern is
+   * `/foo/**` or `C:\foo\**` and the path is `/` or `C:\`.
+   */
+  private readonly rootRegExp: RegExp
+
+  /* eslint-disable no-dupe-class-members */
+  // Disable no-dupe-class-members due to false positive for method overload
+  // https://github.com/typescript-eslint/typescript-eslint/issues/291
+
+  constructor(pattern: string)
+  constructor(negate: boolean, segments: string[])
+  constructor(patternOrNegate: string | boolean, segments?: string[]) {
+    // Pattern overload
+    let pattern: string
+    if (typeof patternOrNegate === 'string') {
+      pattern = patternOrNegate.trim()
+    }
+    // Segments overload
+    else {
+      // Convert to pattern
+      segments = segments || []
+      assert(segments.length, `Parameter 'segments' must not empty`)
+      const root = Pattern.getLiteral(segments[0])
+      assert(
+        root && pathHelper.hasAbsoluteRoot(root),
+        `Parameter 'segments' first element must be a root path`
+      )
+      pattern = new Path(segments).toString().trim()
+      if (patternOrNegate) {
+        pattern = `!${pattern}`
+      }
+    }
+
+    // Negate
+    while (pattern.startsWith('!')) {
+      this.negate = !this.negate
+      pattern = pattern.substr(1).trim()
+    }
+
+    // Normalize slashes and ensures absolute root
+    pattern = Pattern.fixupPattern(pattern)
+
+    // Segments
+    this.segments = new Path(pattern).segments
+
+    // Trailing slash indicates the pattern should only match directories, not regular files
+    this.trailingSeparator = pathHelper
+      .normalizeSeparators(pattern)
+      .endsWith(path.sep)
+    pattern = pathHelper.safeTrimTrailingSeparator(pattern)
+
+    // Search path (literal path prior to the first glob segment)
+    let foundGlob = false
+    const searchSegments = this.segments
+      .map(x => Pattern.getLiteral(x))
+      .filter(x => !foundGlob && !(foundGlob = x === ''))
+    this.searchPath = new Path(searchSegments).toString()
+
+    // Root RegExp (required when determining partial match)
+    this.rootRegExp = new RegExp(
+      Pattern.regExpEscape(searchSegments[0]),
+      IS_WINDOWS ? 'i' : ''
+    )
+
+    // Create minimatch
+    const minimatchOptions: IMinimatchOptions = {
+      dot: true,
+      nobrace: true,
+      nocase: IS_WINDOWS,
+      nocomment: true,
+      noext: true,
+      nonegate: true
+    }
+    pattern = IS_WINDOWS ? pattern.replace(/\\/g, '/') : pattern
+    this.minimatch = new Minimatch(pattern, minimatchOptions)
+  }
+
+  /**
+   * Matches the pattern against the specified path
+   */
+  match(itemPath: string): MatchKind {
+    // Last segment is globstar?
+    if (this.segments[this.segments.length - 1] === '**') {
+      // Normalize slashes
+      itemPath = pathHelper.normalizeSeparators(itemPath)
+
+      // Append a trailing slash. Otherwise Minimatch will not match the directory immediately
+      // preceeding the globstar. For example, given the pattern `/foo/**`, Minimatch returns
+      // false for `/foo` but returns true for `/foo/`. Append a trailing slash to handle that quirk.
+      if (!itemPath.endsWith(path.sep)) {
+        // Note, this is safe because the constructor ensures the pattern has an absolute root.
+        // For example, formats like C: and C:foo on Windows are resolved to an aboslute root.
+        itemPath = `${itemPath}${path.sep}`
+      }
+    } else {
+      // Normalize slashes and trim unnecessary trailing slash
+      itemPath = pathHelper.safeTrimTrailingSeparator(itemPath)
+    }
+
+    // Match
+    if (this.minimatch.match(itemPath)) {
+      return this.trailingSeparator ? MatchKind.Directory : MatchKind.All
+    }
+
+    return MatchKind.None
+  }
+
+  /**
+   * Indicates whether the pattern may match descendants of the specified path
+   */
+  partialMatch(itemPath: string): boolean {
+    // Normalize slashes and trim unnecessary trailing slash
+    itemPath = pathHelper.safeTrimTrailingSeparator(itemPath)
+
+    // matchOne does not handle root path correctly
+    if (pathHelper.dirname(itemPath) === itemPath) {
+      return this.rootRegExp.test(itemPath)
+    }
+
+    return this.minimatch.matchOne(
+      itemPath.split(IS_WINDOWS ? /\\+/ : /\/+/),
+      this.minimatch.set[0],
+      true
+    )
+  }
+
+  /**
+   * Escapes glob patterns within a path
+   */
+  static globEscape(s: string): string {
+    return (IS_WINDOWS ? s : s.replace(/\\/g, '\\\\')) // escape '\' on Linux/macOS
+      .replace(/(\[)(?=[^/]+\])/g, '[[]') // escape '[' when ']' follows within the path segment
+      .replace(/\?/g, '[?]') // escape '?'
+      .replace(/\*/g, '[*]') // escape '*'
+  }
+
+  /**
+   * Normalizes slashes and ensures absolute root
+   */
+  private static fixupPattern(pattern: string): string {
+    // Empty
+    assert(pattern, 'pattern cannot be empty')
+
+    // Must not contain `.` segment, unless first segment
+    // Must not contain `..` segment
+    const literalSegments = new Path(pattern).segments.map(x =>
+      Pattern.getLiteral(x)
+    )
+    assert(
+      literalSegments.every((x, i) => (x !== '.' || i === 0) && x !== '..'),
+      `Invalid pattern '${pattern}'. Relative pathing '.' and '..' is not allowed.`
+    )
+
+    // Must not contain globs in root, e.g. Windows UNC path \\foo\b*r
+    assert(
+      !pathHelper.hasRoot(pattern) || literalSegments[0],
+      `Invalid pattern '${pattern}'. Root segment must not contain globs.`
+    )
+
+    // Normalize slashes
+    pattern = pathHelper.normalizeSeparators(pattern)
+
+    // Replace leading `.` segment
+    if (pattern === '.' || pattern.startsWith(`.${path.sep}`)) {
+      pattern = Pattern.globEscape(process.cwd()) + pattern.substr(1)
+    }
+    // Replace leading `~` segment
+    else if (pattern === '~' || pattern.startsWith(`~${path.sep}`)) {
+      const homedir = os.homedir()
+      assert(homedir, 'Unable to determine HOME directory')
+      assert(
+        pathHelper.hasAbsoluteRoot(homedir),
+        `Expected HOME directory to be a rooted path. Actual '${homedir}'`
+      )
+      pattern = Pattern.globEscape(homedir) + pattern.substr(1)
+    }
+    // Replace relative drive root, e.g. pattern is C: or C:foo
+    else if (
+      IS_WINDOWS &&
+      (pattern.match(/^[A-Z]:$/i) || pattern.match(/^[A-Z]:[^\\]/i))
+    ) {
+      let root = pathHelper.ensureAbsoluteRoot(
+        'C:\\dummy-root',
+        pattern.substr(0, 2)
+      )
+      if (pattern.length > 2 && !root.endsWith('\\')) {
+        root += '\\'
+      }
+      pattern = Pattern.globEscape(root) + pattern.substr(2)
+    }
+    // Replace relative root, e.g. pattern is \ or \foo
+    else if (IS_WINDOWS && (pattern === '\\' || pattern.match(/^\\[^\\]/))) {
+      let root = pathHelper.ensureAbsoluteRoot('C:\\dummy-root', '\\')
+      if (!root.endsWith('\\')) {
+        root += '\\'
+      }
+      pattern = Pattern.globEscape(root) + pattern.substr(1)
+    }
+    // Otherwise ensure absolute root
+    else {
+      pattern = pathHelper.ensureAbsoluteRoot(
+        Pattern.globEscape(process.cwd()),
+        pattern
+      )
+    }
+
+    return pathHelper.normalizeSeparators(pattern)
+  }
+
+  /**
+   * Attempts to unescape a pattern segment to create a literal path segment.
+   * Otherwise returns empty string.
+   */
+  private static getLiteral(segment: string): string {
+    let literal = ''
+    for (let i = 0; i < segment.length; i++) {
+      const c = segment[i]
+      // Escape
+      if (c === '\\' && !IS_WINDOWS && i + 1 < segment.length) {
+        literal += segment[++i]
+        continue
+      }
+      // Wildcard
+      else if (c === '*' || c === '?') {
+        return ''
+      }
+      // Character set
+      else if (c === '[' && i + 1 < segment.length) {
+        let set = ''
+        let closed = -1
+        for (let i2 = i + 1; i2 < segment.length; i2++) {
+          const c2 = segment[i2]
+          // Escape
+          if (c2 === '\\' && !IS_WINDOWS && i2 + 1 < segment.length) {
+            set += segment[++i2]
+            continue
+          }
+          // Closed
+          else if (c2 === ']') {
+            closed = i2
+            break
+          }
+          // Otherwise
+          else {
+            set += c2
+          }
+        }
+
+        // Closed?
+        if (closed >= 0) {
+          // Cannot convert
+          if (set.length > 1) {
+            return ''
+          }
+
+          // Convert to literal
+          if (set) {
+            literal += set
+            i = closed
+            continue
+          }
+        }
+
+        // Otherwise fall thru
+      }
+
+      // Append
+      literal += c
+    }
+
+    return literal
+  }
+
+  /**
+   * Escapes regexp special characters
+   * https://javascript.info/regexp-escaping
+   */
+  private static regExpEscape(s: string): string {
+    return s.replace(/[[\\^$.|?*+()]/g, '\\$&')
+  }
+}
diff --git a/packages/glob/src/internal-search-state.ts b/packages/glob/src/internal-search-state.ts
new file mode 100644
index 0000000..590db3d
--- /dev/null
+++ b/packages/glob/src/internal-search-state.ts
@@ -0,0 +1,9 @@
+export class SearchState {
+  readonly path: string
+  readonly level: number
+
+  constructor(path: string, level: number) {
+    this.path = path
+    this.level = level
+  }
+}
diff --git a/packages/glob/tsconfig.json b/packages/glob/tsconfig.json
new file mode 100644
index 0000000..a8b812a
--- /dev/null
+++ b/packages/glob/tsconfig.json
@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "baseUrl": "./",
+    "outDir": "./lib",
+    "rootDir": "./src"
+  },
+  "include": [
+    "./src"
+  ]
+}
\ No newline at end of file


Test patch:
diff --git a/packages/glob/__tests__/glob.test.ts b/packages/glob/__tests__/glob.test.ts
new file mode 100644
index 0000000..810117a
--- /dev/null
+++ b/packages/glob/__tests__/glob.test.ts
@@ -0,0 +1,671 @@
+import * as child from 'child_process'
+import * as glob from '../src/glob'
+import * as io from '../../io/src/io'
+import * as path from 'path'
+import {promises as fs} from 'fs'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+/**
+ * These test focus on the ability of glob to find files
+ * and not on the pattern matching aspect
+ */
+describe('glob', () => {
+  beforeAll(async () => {
+    await io.rmRF(getTestTemp())
+  })
+
+  it('detects cycle', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/file
+    //   <root>/symDir -> <root>
+    const root = path.join(getTestTemp(), 'detects-cycle')
+    await fs.mkdir(root, {recursive: true})
+    await fs.writeFile(path.join(root, 'file'), 'test file content')
+    await createSymlinkDir(root, path.join(root, 'symDir'))
+
+    const itemPaths = await glob.glob(root)
+    expect(itemPaths).toEqual([root, path.join(root, 'file')])
+    // todo: ? expect(itemPaths[2]).toBe(path.join(root, 'symDir'))
+  })
+
+  it('detects deep cycle starting from middle', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/file-under-root
+    //   <root>/folder-a
+    //   <root>/folder-a/file-under-a
+    //   <root>/folder-a/folder-b
+    //   <root>/folder-a/folder-b/file-under-b
+    //   <root>/folder-a/folder-b/folder-c
+    //   <root>/folder-a/folder-b/folder-c/file-under-c
+    //   <root>/folder-a/folder-b/folder-c/sym-folder -> <root>
+    const root = path.join(
+      getTestTemp(),
+      'detects-deep-cycle-starting-from-middle'
+    )
+    await fs.mkdir(path.join(root, 'folder-a', 'folder-b', 'folder-c'), {
+      recursive: true
+    })
+    await fs.writeFile(
+      path.join(root, 'file-under-root'),
+      'test file under root contents'
+    )
+    await fs.writeFile(
+      path.join(root, 'folder-a', 'file-under-a'),
+      'test file under a contents'
+    )
+    await fs.writeFile(
+      path.join(root, 'folder-a', 'folder-b', 'file-under-b'),
+      'test file under b contents'
+    )
+    await fs.writeFile(
+      path.join(root, 'folder-a', 'folder-b', 'folder-c', 'file-under-c'),
+      'test file under c contents'
+    )
+    await createSymlinkDir(
+      root,
+      path.join(root, 'folder-a', 'folder-b', 'folder-c', 'sym-folder')
+    )
+    await fs.stat(
+      path.join(
+        root,
+        'folder-a',
+        'folder-b',
+        'folder-c',
+        'sym-folder',
+        'file-under-root'
+      )
+    )
+
+    const itemPaths = await glob.glob(path.join(root, 'folder-a', 'folder-b'))
+    expect(itemPaths).toEqual([
+      path.join(root, 'folder-a', 'folder-b'),
+      path.join(root, 'folder-a', 'folder-b', 'file-under-b'),
+      path.join(root, 'folder-a', 'folder-b', 'folder-c'),
+      path.join(root, 'folder-a', 'folder-b', 'folder-c', 'file-under-c'),
+      path.join(root, 'folder-a', 'folder-b', 'folder-c', 'sym-folder'),
+      path.join(
+        root,
+        'folder-a',
+        'folder-b',
+        'folder-c',
+        'sym-folder',
+        'file-under-root'
+      ),
+      path.join(
+        root,
+        'folder-a',
+        'folder-b',
+        'folder-c',
+        'sym-folder',
+        'folder-a'
+      ),
+      path.join(
+        root,
+        'folder-a',
+        'folder-b',
+        'folder-c',
+        'sym-folder',
+        'folder-a',
+        'file-under-a'
+      )
+    ])
+  })
+
+  it('detects cycle starting from symlink', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/file
+    //   <root>/symDir -> <root>
+    const root: string = path.join(
+      getTestTemp(),
+      'detects-cycle-starting-from-symlink'
+    )
+    await fs.mkdir(root, {recursive: true})
+    await fs.writeFile(path.join(root, 'file'), 'test file content')
+    await createSymlinkDir(root, path.join(root, 'symDir'))
+    await fs.stat(path.join(root, 'symDir'))
+
+    const itemPaths = await glob.glob(path.join(root, 'symDir'))
+    expect(itemPaths).toEqual([
+      path.join(root, 'symDir'),
+      path.join(root, 'symDir', 'file')
+    ])
+    // todo: ? expect(itemPaths[2]).toBe(path.join(root, 'symDir', 'symDir'));
+  })
+
+  it('does not follow symlink when followSymbolicLink=false', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/realDir
+    //   <root>/realDir/file
+    //   <root>/symDir -> <root>/realDir
+    const root = path.join(
+      getTestTemp(),
+      'does-not-follow-symlink-when-follow-false'
+    )
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(
+      path.join(root, 'realDir'),
+      path.join(root, 'symDir')
+    )
+
+    const itemPaths = await glob.glob(root, {followSymbolicLinks: false})
+    expect(itemPaths).toEqual([
+      root,
+      path.join(root, 'realDir'),
+      path.join(root, 'realDir', 'file'),
+      path.join(root, 'symDir')
+    ])
+  })
+
+  it('does not follow symlink when search path is symlink and followSymbolicLink=false', async () => {
+    // Create the following layout:
+    //   realDir
+    //   realDir/file
+    //   symDir -> realDir
+    const root = path.join(
+      getTestTemp(),
+      'does-not-follow-symlink-when-search-path-is-symlink-and-follow-false'
+    )
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(
+      path.join(root, 'realDir'),
+      path.join(root, 'symDir')
+    )
+
+    const itemPaths = await glob.glob(path.join(root, 'symDir'), {
+      followSymbolicLinks: false
+    })
+    expect(itemPaths).toEqual([path.join(root, 'symDir')])
+  })
+
+  it('does not return broken symlink', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/brokenSym -> <root>/noSuch
+    //   <root>/realDir
+    //   <root>/realDir/file
+    //   <root>/symDir -> <root>/realDir
+    const root = path.join(getTestTemp(), 'does-not-return-broken-symlink')
+    await fs.mkdir(root, {recursive: true})
+    await createSymlinkDir(
+      path.join(root, 'noSuch'),
+      path.join(root, 'brokenSym')
+    )
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(
+      path.join(root, 'realDir'),
+      path.join(root, 'symDir')
+    )
+
+    const itemPaths = await glob.glob(root)
+    expect(itemPaths).toEqual([
+      root,
+      path.join(root, 'realDir'),
+      path.join(root, 'realDir', 'file'),
+      path.join(root, 'symDir'),
+      path.join(root, 'symDir', 'file')
+    ])
+  })
+
+  it('does not return broken symlink when search path is broken symlink', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/brokenSym -> <root>/noSuch
+    const root = path.join(
+      getTestTemp(),
+      'does-not-return-broken-symlink-when-search-path-is-broken-symlink'
+    )
+    await fs.mkdir(root, {recursive: true})
+    const brokenSymPath = path.join(root, 'brokenSym')
+    await createSymlinkDir(path.join(root, 'noSuch'), brokenSymPath)
+    await fs.lstat(brokenSymPath)
+
+    const itemPaths = await glob.glob(brokenSymPath)
+    expect(itemPaths).toEqual([])
+  })
+
+  it('does not search paths that are not partial matches', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/realDir
+    //   <root>/realDir/nested
+    //   <root>/realDir/nested/file
+    //   <root>/realDir2
+    //   <root>/realDir2/nested2
+    //   <root>/realDir2/nested2/symDir -> <root>/noSuch
+    const root = path.join(
+      getTestTemp(),
+      'does-not-search-paths-that-are-not-partial-matches'
+    )
+    await fs.mkdir(path.join(root, 'realDir', 'nested'), {recursive: true})
+    await fs.writeFile(
+      path.join(root, 'realDir', 'nested', 'file'),
+      'test file content'
+    )
+    await fs.mkdir(path.join(root, 'realDir2', 'nested2'), {recursive: true})
+    await createSymlinkDir(
+      path.join(root, 'noSuch'),
+      path.join(root, 'realDir2', 'nested2', 'symDir')
+    )
+
+    const options: glob.IGlobOptions = {
+      followSymbolicLinks: true,
+      omitBrokenSymbolicLinks: false
+    }
+
+    // Should throw
+    try {
+      await glob.glob(`${root}/*Dir*/*nested*/*`, options)
+      throw new Error('should not reach here')
+    } catch (err) {
+      expect(err.message).toMatch(/broken symbolic link/i)
+    }
+
+    // Not partial match
+    let itemPaths = await glob.glob(`${root}/*Dir/*nested*/*`, options)
+    expect(itemPaths).toEqual([path.join(root, 'realDir', 'nested', 'file')])
+
+    // Not partial match
+    itemPaths = await glob.glob(`${root}/*Dir*/*nested/*`, options)
+    expect(itemPaths).toEqual([path.join(root, 'realDir', 'nested', 'file')])
+  })
+
+  it('does not throw for broken symlinks that are not matches or partial matches', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/realDir
+    //   <root>/realDir/file
+    //   <root>/symDir -> <root>/noSuch
+    const root = path.join(
+      getTestTemp(),
+      'does-not-throw-for-broken-symlinks-that-are-not-matches-or-partial-matches'
+    )
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(path.join(root, 'noSuch'), path.join(root, 'symDir'))
+
+    const options: glob.IGlobOptions = {
+      followSymbolicLinks: true,
+      omitBrokenSymbolicLinks: false
+    }
+
+    // Match should throw
+    try {
+      await glob.glob(`${root}/*`, options)
+      throw new Error('should not reach here')
+    } catch (err) {
+      expect(err.message).toMatch(/broken symbolic link/i)
+    }
+
+    // Partial match should throw
+    try {
+      await glob.glob(`${root}/*/*`, options)
+      throw new Error('should not reach here')
+    } catch (err) {
+      expect(err.message).toMatch(/broken symbolic link/i)
+    }
+
+    // Not match or partial match
+    const itemPaths = await glob.glob(`${root}/*eal*/*`, options)
+    expect(itemPaths).toEqual([path.join(root, 'realDir', 'file')])
+  })
+
+  it('follows symlink', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/realDir
+    //   <root>/realDir/file
+    //   <root>/symDir -> <root>/realDir
+    const root = path.join(getTestTemp(), 'follows-symlink')
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(
+      path.join(root, 'realDir'),
+      path.join(root, 'symDir')
+    )
+
+    const itemPaths = await glob.glob(root)
+    expect(itemPaths).toEqual([
+      root,
+      path.join(root, 'realDir'),
+      path.join(root, 'realDir', 'file'),
+      path.join(root, 'symDir'),
+      path.join(root, 'symDir', 'file')
+    ])
+  })
+
+  it('follows symlink when search path is symlink', async () => {
+    // Create the following layout:
+    //   realDir
+    //   realDir/file
+    //   symDir -> realDir
+    const root = path.join(
+      getTestTemp(),
+      'follows-symlink-when-search-path-is-symlink'
+    )
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(
+      path.join(root, 'realDir'),
+      path.join(root, 'symDir')
+    )
+
+    const itemPaths = await glob.glob(path.join(root, 'symDir'))
+    expect(itemPaths).toEqual([
+      path.join(root, 'symDir'),
+      path.join(root, 'symDir', 'file')
+    ])
+  })
+
+  it('returns broken symlink when followSymbolicLinks=false', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/brokenSym -> <root>/noSuch
+    //   <root>/realDir
+    //   <root>/realDir/file
+    //   <root>/symDir -> <root>/realDir
+    const root = path.join(
+      getTestTemp(),
+      'returns-broken-symlink-when-follow-false'
+    )
+    await fs.mkdir(root, {recursive: true})
+    await createSymlinkDir(
+      path.join(root, 'noSuch'),
+      path.join(root, 'brokenSym')
+    )
+    await fs.mkdir(path.join(root, 'realDir'), {recursive: true})
+    await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content')
+    await createSymlinkDir(
+      path.join(root, 'realDir'),
+      path.join(root, 'symDir')
+    )
+
+    const itemPaths = await glob.glob(root, {followSymbolicLinks: false})
+    expect(itemPaths).toEqual([
+      root,
+      path.join(root, 'brokenSym'),
+      path.join(root, 'realDir'),
+      path.join(root, 'realDir', 'file'),
+      path.join(root, 'symDir')
+    ])
+  })
+
+  it('returns broken symlink when search path is broken symlink and followSymbolicLinks=false', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/brokenSym -> <root>/noSuch
+    const root = path.join(
+      getTestTemp(),
+      'returns-broken-symlink-when-search-path-is-broken-symlink-and-follow-false'
+    )
+    await fs.mkdir(root, {recursive: true})
+    const brokenSymPath = path.join(root, 'brokenSym')
+    await createSymlinkDir(path.join(root, 'noSuch'), brokenSymPath)
+
+    const itemPaths = await glob.glob(brokenSymPath, {
+      followSymbolicLinks: false
+    })
+    expect(itemPaths).toEqual([brokenSymPath])
+  })
+
+  it('returns depth first', async () => {
+    // Create the following layout:
+    //   <root>/a-file
+    //   <root>/b-folder
+    //   <root>/b-folder/a-file
+    //   <root>/b-folder/b-folder
+    //   <root>/b-folder/b-folder/file
+    //   <root>/b-folder/c-file
+    //   <root>/c-file
+    const root = path.join(getTestTemp(), 'returns-depth-first')
+    await fs.mkdir(path.join(root, 'b-folder', 'b-folder'), {recursive: true})
+    await fs.writeFile(path.join(root, 'a-file'), 'test a-file content')
+    await fs.writeFile(
+      path.join(root, 'b-folder', 'a-file'),
+      'test b-folder/a-file content'
+    )
+    await fs.writeFile(
+      path.join(root, 'b-folder', 'b-folder', 'file'),
+      'test b-folder/b-folder/file content'
+    )
+    await fs.writeFile(
+      path.join(root, 'b-folder', 'c-file'),
+      'test b-folder/c-file content'
+    )
+    await fs.writeFile(path.join(root, 'c-file'), 'test c-file content')
+
+    const itemPaths = await glob.glob(root)
+    expect(itemPaths).toEqual([
+      root,
+      path.join(root, 'a-file'),
+      path.join(root, 'b-folder'),
+      path.join(root, 'b-folder', 'a-file'),
+      path.join(root, 'b-folder', 'b-folder'),
+      path.join(root, 'b-folder', 'b-folder', 'file'),
+      path.join(root, 'b-folder', 'c-file'),
+      path.join(root, 'c-file')
+    ])
+  })
+
+  it('returns descendants', async () => {
+    // Create the following layout:
+    //   <root>/file-1
+    //   <root>/dir-1
+    //   <root>/dir-1/file-2
+    //   <root>/dir-1/dir-2
+    //   <root>/dir-1/dir-2/file-3
+    const root = path.join(getTestTemp(), 'returns-descendants')
+    await fs.mkdir(path.join(root, 'dir-1', 'dir-2'), {recursive: true})
+    await fs.writeFile(path.join(root, 'file-1'), '')
+    await fs.writeFile(path.join(root, 'dir-1', 'file-2'), '')
+    await fs.writeFile(path.join(root, 'dir-1', 'dir-2', 'file-3'), '')
+
+    // When pattern ends with `/**/`
+    let pattern = `${root}${path.sep}**${path.sep}`
+    expect(
+      await glob.glob(pattern, {
+        implicitDescendants: false
+      })
+    ).toHaveLength(3) // sanity check
+    expect(await glob.glob(pattern)).toEqual([
+      root,
+      path.join(root, 'dir-1'),
+      path.join(root, 'dir-1', 'dir-2'),
+      path.join(root, 'dir-1', 'dir-2', 'file-3'),
+      path.join(root, 'dir-1', 'file-2'),
+      path.join(root, 'file-1')
+    ])
+
+    // When pattern ends with something other than `/**/`
+    pattern = `${root}${path.sep}**${path.sep}dir-?`
+    expect(
+      await glob.glob(pattern, {
+        implicitDescendants: false
+      })
+    ).toHaveLength(2) // sanity check
+    expect(await glob.glob(pattern)).toEqual([
+      path.join(root, 'dir-1'),
+      path.join(root, 'dir-1', 'dir-2'),
+      path.join(root, 'dir-1', 'dir-2', 'file-3'),
+      path.join(root, 'dir-1', 'file-2')
+    ])
+  })
+
+  it('returns directories only when trailing slash and implicit descendants false', async () => {
+    // Create the following layout:
+    //   <root>/file-1
+    //   <root>/dir-1
+    //   <root>/dir-1/file-2
+    //   <root>/dir-1/dir-2
+    //   <root>/dir-1/dir-2/file-3
+    const root = path.join(
+      getTestTemp(),
+      'returns-directories-only-when-trailing-slash-and-implicit-descendants-false'
+    )
+    await fs.mkdir(path.join(root, 'dir-1', 'dir-2'), {recursive: true})
+    await fs.writeFile(path.join(root, 'file-1'), '')
+    await fs.writeFile(path.join(root, 'dir-1', 'file-2'), '')
+    await fs.writeFile(path.join(root, 'dir-1', 'dir-2', 'file-3'), '')
+
+    const pattern = `${root}${path.sep}**${path.sep}`
+    expect(await glob.glob(pattern)).toHaveLength(6) // sanity check
+    expect(
+      await glob.glob(pattern, {
+        implicitDescendants: false
+      })
+    ).toEqual([
+      root,
+      path.join(root, 'dir-1'),
+      path.join(root, 'dir-1', 'dir-2')
+    ])
+  })
+
+  it('returns empty when search path does not exist', async () => {
+    const itemPaths = await glob.glob(path.join(getTestTemp(), 'nosuch'))
+    expect(itemPaths).toEqual([])
+  })
+
+  it('returns hidden files', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/.emptyFolder
+    //   <root>/.file
+    //   <root>/.folder
+    //   <root>/.folder/file
+    const root = path.join(getTestTemp(), 'returns-hidden-files')
+    await createHiddenDirectory(path.join(root, '.emptyFolder'))
+    await createHiddenDirectory(path.join(root, '.folder'))
+    await createHiddenFile(path.join(root, '.file'), 'test .file content')
+    await fs.writeFile(
+      path.join(root, '.folder', 'file'),
+      'test .folder/file content'
+    )
+
+    const itemPaths = await glob.glob(root)
+    expect(itemPaths).toEqual([
+      root,
+      path.join(root, '.emptyFolder'),
+      path.join(root, '.file'),
+      path.join(root, '.folder'),
+      path.join(root, '.folder', 'file')
+    ])
+  })
+
+  it('returns normalized paths', async () => {
+    // Create the following layout:
+    //   <root>/hello/world.txt
+    const root: string = path.join(getTestTemp(), 'returns-normalized-paths')
+    await fs.mkdir(path.join(root, 'hello'), {recursive: true})
+    await fs.writeFile(path.join(root, 'hello', 'world.txt'), '')
+
+    const itemPaths = await glob.glob(
+      `${root}${path.sep}${path.sep}${path.sep}hello`
+    )
+    expect(itemPaths).toEqual([
+      path.join(root, 'hello'),
+      path.join(root, 'hello', 'world.txt')
+    ])
+  })
+
+  it('throws when match broken symlink and omitBrokenSymbolicLinks=false', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/brokenSym -> <root>/noSuch
+    const root = path.join(
+      getTestTemp(),
+      'throws-when-match-broken-symlink-and-omit-false'
+    )
+    await fs.mkdir(root, {recursive: true})
+    await createSymlinkDir(
+      path.join(root, 'noSuch'),
+      path.join(root, 'brokenSym')
+    )
+
+    try {
+      await glob.glob(root, {omitBrokenSymbolicLinks: false})
+      throw new Error('Expected tl.find to throw')
+    } catch (err) {
+      expect(err.message).toMatch(/broken symbolic link/)
+    }
+  })
+
+  it('throws when search path is broken symlink and omitBrokenSymbolicLinks=false', async () => {
+    // Create the following layout:
+    //   <root>
+    //   <root>/brokenSym -> <root>/noSuch
+    const root = path.join(
+      getTestTemp(),
+      'throws-when-search-path-is-broken-symlink-and-omit-false'
+    )
+    await fs.mkdir(root, {recursive: true})
+    const brokenSymPath = path.join(root, 'brokenSym')
+    await createSymlinkDir(path.join(root, 'noSuch'), brokenSymPath)
+    await fs.lstat(brokenSymPath)
+
+    try {
+      await glob.glob(brokenSymPath, {omitBrokenSymbolicLinks: false})
+      throw new Error('Expected tl.find to throw')
+    } catch (err) {
+      expect(err.message).toMatch(/broken symbolic link/)
+    }
+  })
+})
+
+async function createHiddenDirectory(dir: string): Promise<void> {
+  if (!path.basename(dir).match(/^\./)) {
+    throw new Error(`Expected dir '${dir}' to start with '.'.`)
+  }
+
+  await fs.mkdir(dir, {recursive: true})
+  if (IS_WINDOWS) {
+    const result = child.spawnSync('attrib.exe', ['+H', dir])
+    if (result.status !== 0) {
+      const message: string = (result.output || []).join(' ').trim()
+      throw new Error(
+        `Failed to set hidden attribute for directory '${dir}'. ${message}`
+      )
+    }
+  }
+}
+
+async function createHiddenFile(file: string, content: string): Promise<void> {
+  if (!path.basename(file).match(/^\./)) {
+    throw new Error(`Expected dir '${file}' to start with '.'.`)
+  }
+
+  await fs.mkdir(path.dirname(file), {recursive: true})
+  await fs.writeFile(file, content)
+
+  if (IS_WINDOWS) {
+    const result = child.spawnSync('attrib.exe', ['+H', file])
+    if (result.status !== 0) {
+      const message: string = (result.output || []).join(' ').trim()
+      throw new Error(
+        `Failed to set hidden attribute for file '${file}'. ${message}`
+      )
+    }
+  }
+}
+
+function getTestTemp(): string {
+  return path.join(__dirname, '_temp', 'glob')
+}
+
+/**
+ * Creates a symlink directory on OSX/Linux, and a junction point directory on Windows.
+ * A symlink directory is not created on Windows since it requires an elevated context.
+ */
+async function createSymlinkDir(real: string, link: string): Promise<void> {
+  if (IS_WINDOWS) {
+    await fs.symlink(real, link, 'junction')
+  } else {
+    await fs.symlink(real, link)
+  }
+}
diff --git a/packages/glob/__tests__/internal-path-helper.test.ts b/packages/glob/__tests__/internal-path-helper.test.ts
new file mode 100644
index 0000000..48a10da
--- /dev/null
+++ b/packages/glob/__tests__/internal-path-helper.test.ts
@@ -0,0 +1,640 @@
+import * as pathHelper from '../src/internal-path-helper'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+describe('path-helper', () => {
+  it('dirname interprets directory name from paths', () => {
+    assertDirectoryName('', '.')
+    assertDirectoryName('.', '.')
+    assertDirectoryName('..', '.')
+    assertDirectoryName('hello', '.')
+    assertDirectoryName('hello/', '.')
+    assertDirectoryName('hello/world', 'hello')
+
+    if (IS_WINDOWS) {
+      // Removes redundant slashes
+      assertDirectoryName('C:\\\\hello\\\\\\world\\\\', 'C:\\hello')
+      assertDirectoryName('C://hello///world//', 'C:\\hello')
+      // Relative root:
+      assertDirectoryName('\\hello\\\\world\\\\again\\\\', '\\hello\\world')
+      assertDirectoryName('/hello///world//again//', '\\hello\\world')
+      // UNC:
+      assertDirectoryName('\\\\hello\\world\\again\\', '\\\\hello\\world')
+      assertDirectoryName(
+        '\\\\hello\\\\\\world\\\\again\\\\',
+        '\\\\hello\\world'
+      )
+      assertDirectoryName(
+        '\\\\\\hello\\\\\\world\\\\again\\\\',
+        '\\\\hello\\world'
+      )
+      assertDirectoryName(
+        '\\\\\\\\hello\\\\\\world\\\\again\\\\',
+        '\\\\hello\\world'
+      )
+      assertDirectoryName('//hello///world//again//', '\\\\hello\\world')
+      assertDirectoryName('///hello///world//again//', '\\\\hello\\world')
+      assertDirectoryName('/////hello///world//again//', '\\\\hello\\world')
+      // Relative:
+      assertDirectoryName('hello\\world', 'hello')
+
+      // Directory trimming
+      assertDirectoryName('a:/hello', 'a:\\')
+      assertDirectoryName('z:/hello', 'z:\\')
+      assertDirectoryName('A:/hello', 'A:\\')
+      assertDirectoryName('Z:/hello', 'Z:\\')
+      assertDirectoryName('C:/', 'C:\\')
+      assertDirectoryName('C:/hello', 'C:\\')
+      assertDirectoryName('C:/hello/', 'C:\\')
+      assertDirectoryName('C:/hello/world', 'C:\\hello')
+      assertDirectoryName('C:/hello/world/', 'C:\\hello')
+      assertDirectoryName('C:', 'C:')
+      assertDirectoryName('C:hello', 'C:')
+      assertDirectoryName('C:hello/', 'C:')
+      assertDirectoryName('C:hello/world', 'C:hello')
+      assertDirectoryName('C:hello/world/', 'C:hello')
+      assertDirectoryName('/', '\\')
+      assertDirectoryName('/hello', '\\')
+      assertDirectoryName('/hello/', '\\')
+      assertDirectoryName('/hello/world', '\\hello')
+      assertDirectoryName('/hello/world/', '\\hello')
+      assertDirectoryName('\\', '\\')
+      assertDirectoryName('\\hello', '\\')
+      assertDirectoryName('\\hello\\', '\\')
+      assertDirectoryName('\\hello\\world', '\\hello')
+      assertDirectoryName('\\hello\\world\\', '\\hello')
+      assertDirectoryName('//hello', '\\\\hello')
+      assertDirectoryName('//hello/', '\\\\hello')
+      assertDirectoryName('//hello/world', '\\\\hello\\world')
+      assertDirectoryName('//hello/world/', '\\\\hello\\world')
+      assertDirectoryName('\\\\hello', '\\\\hello')
+      assertDirectoryName('\\\\hello\\', '\\\\hello')
+      assertDirectoryName('\\\\hello\\world', '\\\\hello\\world')
+      assertDirectoryName('\\\\hello\\world\\', '\\\\hello\\world')
+      assertDirectoryName('//hello/world/again', '\\\\hello\\world')
+      assertDirectoryName('//hello/world/again/', '\\\\hello\\world')
+      assertDirectoryName('hello/world/', 'hello')
+      assertDirectoryName('hello/world/again', 'hello\\world')
+      assertDirectoryName('../../hello', '..\\..')
+    } else {
+      // Should not converts slashes
+      assertDirectoryName('/hello\\world', '/')
+      assertDirectoryName('/hello\\world/', '/')
+      assertDirectoryName('\\\\hello\\world\\again', '.')
+      assertDirectoryName('\\\\hello\\world/', '.')
+      assertDirectoryName('\\\\hello\\world/again', '\\\\hello\\world')
+      assertDirectoryName('hello\\world', '.')
+      assertDirectoryName('hello\\world/', '.')
+
+      // Should remove redundant slashes (rooted paths; UNC format not special)
+      assertDirectoryName('//hello', '/')
+      assertDirectoryName('//hello/world', '/hello')
+      assertDirectoryName('//hello/world/', '/hello')
+      assertDirectoryName('//hello//world//', '/hello')
+      assertDirectoryName('///hello////world///', '/hello')
+
+      // Should remove redundant slashes (relative paths)
+      assertDirectoryName('hello//world//again//', 'hello/world')
+      assertDirectoryName('hello///world///again///', 'hello/world')
+
+      // Directory trimming (Windows drive root format not special)
+      assertDirectoryName('C:/', '.')
+      assertDirectoryName('C:/hello', 'C:')
+      assertDirectoryName('C:/hello/', 'C:')
+      assertDirectoryName('C:/hello/world', 'C:/hello')
+      assertDirectoryName('C:/hello/world/', 'C:/hello')
+      assertDirectoryName('C:', '.')
+      assertDirectoryName('C:hello', '.')
+      assertDirectoryName('C:hello/', '.')
+      assertDirectoryName('C:hello/world', 'C:hello')
+      assertDirectoryName('C:hello/world/', 'C:hello')
+
+      // Directory trimming (rooted paths)
+      assertDirectoryName('/', '/')
+      assertDirectoryName('/hello', '/')
+      assertDirectoryName('/hello/', '/')
+      assertDirectoryName('/hello/world', '/hello')
+      assertDirectoryName('/hello/world/', '/hello')
+
+      // Directory trimming (relative paths)
+      assertDirectoryName('hello/world/', 'hello')
+      assertDirectoryName('hello/world/again', 'hello/world')
+      assertDirectoryName('../../hello', '../..')
+    }
+  })
+
+  it('ensureAbsoluteRoot roots paths', () => {
+    if (IS_WINDOWS) {
+      const currentDrive = process.cwd().substr(0, 2)
+      expect(currentDrive.match(/^[A-Z]:$/i)).toBeTruthy()
+      const otherDrive = currentDrive.toUpperCase().startsWith('C')
+        ? 'D:'
+        : 'C:'
+
+      // Preserves relative pathing
+      assertEnsureAbsoluteRoot('C:/foo', '.', `C:/foo\\.`)
+      assertEnsureAbsoluteRoot('C:/foo/..', 'bar', `C:/foo/..\\bar`)
+      assertEnsureAbsoluteRoot('C:/foo', 'bar/../baz', `C:/foo\\bar/../baz`)
+
+      // Already rooted - drive root
+      assertEnsureAbsoluteRoot('D:\\', 'C:/', 'C:/')
+      assertEnsureAbsoluteRoot('D:\\', 'a:/hello', 'a:/hello')
+      assertEnsureAbsoluteRoot('D:\\', 'C:\\', 'C:\\')
+      assertEnsureAbsoluteRoot('D:\\', 'C:\\hello', 'C:\\hello')
+
+      // Already rooted - relative current drive root
+      expect(process.cwd().length).toBeGreaterThan(3) // sanity check not drive root
+      assertEnsureAbsoluteRoot(`${otherDrive}\\`, currentDrive, process.cwd())
+      assertEnsureAbsoluteRoot(
+        `${otherDrive}\\`,
+        `${currentDrive}hello`,
+        `${process.cwd()}\\hello`
+      )
+      assertEnsureAbsoluteRoot(
+        `${otherDrive}\\`,
+        `${currentDrive}hello/world`,
+        `${process.cwd()}\\hello/world`
+      )
+      assertEnsureAbsoluteRoot(
+        `${otherDrive}\\`,
+        `${currentDrive}hello\\world`,
+        `${process.cwd()}\\hello\\world`
+      )
+
+      // Already rooted - relative other drive root
+      assertEnsureAbsoluteRoot(
+        `${currentDrive}\\`,
+        otherDrive,
+        `${otherDrive}\\`
+      )
+      assertEnsureAbsoluteRoot(
+        `${currentDrive}\\`,
+        `${otherDrive}hello`,
+        `${otherDrive}\\hello`
+      )
+      assertEnsureAbsoluteRoot(
+        `${currentDrive}\\`,
+        `${otherDrive}hello/world`,
+        `${otherDrive}\\hello/world`
+      )
+      assertEnsureAbsoluteRoot(
+        `${currentDrive}\\`,
+        `${otherDrive}hello\\world`,
+        `${otherDrive}\\hello\\world`
+      )
+
+      // Already rooted - current drive root
+      assertEnsureAbsoluteRoot(`${otherDrive}\\`, '/', `${currentDrive}\\`)
+      assertEnsureAbsoluteRoot(
+        `${otherDrive}\\`,
+        '/hello',
+        `${currentDrive}\\hello`
+      )
+      assertEnsureAbsoluteRoot(`${otherDrive}\\`, '\\', `${currentDrive}\\`)
+      assertEnsureAbsoluteRoot(
+        `${otherDrive}\\`,
+        '\\hello',
+        `${currentDrive}\\hello`
+      )
+
+      // Already rooted - UNC
+      assertEnsureAbsoluteRoot('D:\\', '//machine/share', '//machine/share')
+      assertEnsureAbsoluteRoot(
+        'D:\\',
+        '\\\\machine\\share',
+        '\\\\machine\\share'
+      )
+
+      // Relative
+      assertEnsureAbsoluteRoot('D:/', 'hello', 'D:/hello')
+      assertEnsureAbsoluteRoot('D:/', 'hello/world', 'D:/hello/world')
+      assertEnsureAbsoluteRoot('D:\\', 'hello', 'D:\\hello')
+      assertEnsureAbsoluteRoot('D:\\', 'hello\\world', 'D:\\hello\\world')
+      assertEnsureAbsoluteRoot('D:/root', 'hello', 'D:/root\\hello')
+      assertEnsureAbsoluteRoot('D:/root', 'hello/world', 'D:/root\\hello/world')
+      assertEnsureAbsoluteRoot('D:\\root', 'hello', 'D:\\root\\hello')
+      assertEnsureAbsoluteRoot(
+        'D:\\root',
+        'hello\\world',
+        'D:\\root\\hello\\world'
+      )
+      assertEnsureAbsoluteRoot('D:/root/', 'hello', 'D:/root/hello')
+      assertEnsureAbsoluteRoot('D:/root/', 'hello/world', 'D:/root/hello/world')
+      assertEnsureAbsoluteRoot('D:\\root\\', 'hello', 'D:\\root\\hello')
+      assertEnsureAbsoluteRoot(
+        'D:\\root\\',
+        'hello\\world',
+        'D:\\root\\hello\\world'
+      )
+    } else {
+      // Preserves relative pathing
+      assertEnsureAbsoluteRoot('/foo', '.', `/foo/.`)
+      assertEnsureAbsoluteRoot('/foo/..', 'bar', `/foo/../bar`)
+      assertEnsureAbsoluteRoot('/foo', 'bar/../baz', `/foo/bar/../baz`)
+
+      // Already rooted
+      assertEnsureAbsoluteRoot('/root', '/', '/')
+      assertEnsureAbsoluteRoot('/root', '/hello', '/hello')
+      assertEnsureAbsoluteRoot('/root', '/hello/world', '/hello/world')
+
+      // Not already rooted - Windows style drive root
+      assertEnsureAbsoluteRoot('/root', 'C:/', '/root/C:/')
+      assertEnsureAbsoluteRoot('/root', 'C:/hello', '/root/C:/hello')
+      assertEnsureAbsoluteRoot('/root', 'C:\\', '/root/C:\\')
+
+      // Not already rooted - Windows style relative drive root
+      assertEnsureAbsoluteRoot('/root', 'C:', '/root/C:')
+      assertEnsureAbsoluteRoot('/root', 'C:hello/world', '/root/C:hello/world')
+
+      // Not already rooted - Windows style current drive root
+      assertEnsureAbsoluteRoot('/root', '\\', '/root/\\')
+      assertEnsureAbsoluteRoot(
+        '/root',
+        '\\hello\\world',
+        '/root/\\hello\\world'
+      )
+
+      // Not already rooted - Windows style UNC
+      assertEnsureAbsoluteRoot(
+        '/root',
+        '\\\\machine\\share',
+        '/root/\\\\machine\\share'
+      )
+
+      // Not already rooted - relative
+      assertEnsureAbsoluteRoot('/', 'hello', '/hello')
+      assertEnsureAbsoluteRoot('/', 'hello/world', '/hello/world')
+      assertEnsureAbsoluteRoot('/', 'hello\\world', '/hello\\world')
+      assertEnsureAbsoluteRoot('/root', 'hello', '/root/hello')
+      assertEnsureAbsoluteRoot('/root', 'hello/world', '/root/hello/world')
+      assertEnsureAbsoluteRoot('/root', 'hello\\world', '/root/hello\\world')
+      assertEnsureAbsoluteRoot('/root/', 'hello', '/root/hello')
+      assertEnsureAbsoluteRoot('/root/', 'hello/world', '/root/hello/world')
+      assertEnsureAbsoluteRoot('/root/', 'hello\\world', '/root/hello\\world')
+      assertEnsureAbsoluteRoot('/root\\', 'hello', '/root\\/hello')
+      assertEnsureAbsoluteRoot('/root\\', 'hello/world', '/root\\/hello/world')
+      assertEnsureAbsoluteRoot(
+        '/root\\',
+        'hello\\world',
+        '/root\\/hello\\world'
+      )
+    }
+  })
+
+  it('hasAbsoluteRoot detects absolute root', () => {
+    if (IS_WINDOWS) {
+      // Drive root
+      assertHasAbsoluteRoot('C:/', true)
+      assertHasAbsoluteRoot('a:/hello', true)
+      assertHasAbsoluteRoot('c:/hello', true)
+      assertHasAbsoluteRoot('z:/hello', true)
+      assertHasAbsoluteRoot('A:/hello', true)
+      assertHasAbsoluteRoot('C:/hello', true)
+      assertHasAbsoluteRoot('Z:/hello', true)
+      assertHasAbsoluteRoot('C:\\', true)
+      assertHasAbsoluteRoot('C:\\hello', true)
+
+      // Relative drive root
+      assertHasAbsoluteRoot('C:', false)
+      assertHasAbsoluteRoot('C:hello', false)
+      assertHasAbsoluteRoot('C:hello/world', false)
+      assertHasAbsoluteRoot('C:hello\\world', false)
+
+      // Current drive root
+      assertHasAbsoluteRoot('/', false)
+      assertHasAbsoluteRoot('/hello', false)
+      assertHasAbsoluteRoot('/hello/world', false)
+      assertHasAbsoluteRoot('\\', false)
+      assertHasAbsoluteRoot('\\hello', false)
+      assertHasAbsoluteRoot('\\hello\\world', false)
+
+      // UNC
+      assertHasAbsoluteRoot('//machine/share', true)
+      assertHasAbsoluteRoot('//machine/share/', true)
+      assertHasAbsoluteRoot('//machine/share/hello', true)
+      assertHasAbsoluteRoot('\\\\machine\\share', true)
+      assertHasAbsoluteRoot('\\\\machine\\share\\', true)
+      assertHasAbsoluteRoot('\\\\machine\\share\\hello', true)
+
+      // Relative
+      assertHasAbsoluteRoot('hello', false)
+      assertHasAbsoluteRoot('hello/world', false)
+      assertHasAbsoluteRoot('hello\\world', false)
+    } else {
+      // Root
+      assertHasAbsoluteRoot('/', true)
+      assertHasAbsoluteRoot('/hello', true)
+      assertHasAbsoluteRoot('/hello/world', true)
+
+      // Windows style drive root - false on OSX/Linux
+      assertHasAbsoluteRoot('C:/', false)
+      assertHasAbsoluteRoot('a:/hello', false)
+      assertHasAbsoluteRoot('c:/hello', false)
+      assertHasAbsoluteRoot('z:/hello', false)
+      assertHasAbsoluteRoot('A:/hello', false)
+      assertHasAbsoluteRoot('C:/hello', false)
+      assertHasAbsoluteRoot('Z:/hello', false)
+      assertHasAbsoluteRoot('C:\\', false)
+      assertHasAbsoluteRoot('C:\\hello', false)
+
+      // Windows style relative drive root - false on OSX/Linux
+      assertHasAbsoluteRoot('C:', false)
+      assertHasAbsoluteRoot('C:hello', false)
+      assertHasAbsoluteRoot('C:hello/world', false)
+      assertHasAbsoluteRoot('C:hello\\world', false)
+
+      // Windows style current drive root - false on OSX/Linux
+      assertHasAbsoluteRoot('\\', false)
+      assertHasAbsoluteRoot('\\hello', false)
+      assertHasAbsoluteRoot('\\hello\\world', false)
+
+      // Windows style UNC - false on OSX/Linux
+      assertHasAbsoluteRoot('\\\\machine\\share', false)
+      assertHasAbsoluteRoot('\\\\machine\\share\\', false)
+      assertHasAbsoluteRoot('\\\\machine\\share\\hello', false)
+
+      // Relative
+      assertHasAbsoluteRoot('hello', false)
+      assertHasAbsoluteRoot('hello/world', false)
+      assertHasAbsoluteRoot('hello\\world', false)
+    }
+  })
+
+  it('hasRoot detects root', () => {
+    if (IS_WINDOWS) {
+      // Drive root
+      assertHasRoot('C:/', true)
+      assertHasRoot('a:/hello', true)
+      assertHasRoot('c:/hello', true)
+      assertHasRoot('z:/hello', true)
+      assertHasRoot('A:/hello', true)
+      assertHasRoot('C:/hello', true)
+      assertHasRoot('Z:/hello', true)
+      assertHasRoot('C:\\', true)
+      assertHasRoot('C:\\hello', true)
+
+      // Relative drive root
+      assertHasRoot('C:', true)
+      assertHasRoot('C:hello', true)
+      assertHasRoot('C:hello/world', true)
+      assertHasRoot('C:hello\\world', true)
+
+      // Current drive root
+      assertHasRoot('/', true)
+      assertHasRoot('/hello', true)
+      assertHasRoot('/hello/world', true)
+      assertHasRoot('\\', true)
+      assertHasRoot('\\hello', true)
+      assertHasRoot('\\hello\\world', true)
+
+      // UNC
+      assertHasRoot('//machine/share', true)
+      assertHasRoot('//machine/share/', true)
+      assertHasRoot('//machine/share/hello', true)
+      assertHasRoot('\\\\machine\\share', true)
+      assertHasRoot('\\\\machine\\share\\', true)
+      assertHasRoot('\\\\machine\\share\\hello', true)
+
+      // Relative
+      assertHasRoot('hello', false)
+      assertHasRoot('hello/world', false)
+      assertHasRoot('hello\\world', false)
+    } else {
+      // Root
+      assertHasRoot('/', true)
+      assertHasRoot('/hello', true)
+      assertHasRoot('/hello/world', true)
+
+      // Windows style drive root - false on OSX/Linux
+      assertHasRoot('C:/', false)
+      assertHasRoot('a:/hello', false)
+      assertHasRoot('c:/hello', false)
+      assertHasRoot('z:/hello', false)
+      assertHasRoot('A:/hello', false)
+      assertHasRoot('C:/hello', false)
+      assertHasRoot('Z:/hello', false)
+      assertHasRoot('C:\\', false)
+      assertHasRoot('C:\\hello', false)
+
+      // Windows style relative drive root - false on OSX/Linux
+      assertHasRoot('C:', false)
+      assertHasRoot('C:hello', false)
+      assertHasRoot('C:hello/world', false)
+      assertHasRoot('C:hello\\world', false)
+
+      // Windows style current drive root - false on OSX/Linux
+      assertHasRoot('\\', false)
+      assertHasRoot('\\hello', false)
+      assertHasRoot('\\hello\\world', false)
+
+      // Windows style UNC - false on OSX/Linux
+      assertHasRoot('\\\\machine\\share', false)
+      assertHasRoot('\\\\machine\\share\\', false)
+      assertHasRoot('\\\\machine\\share\\hello', false)
+
+      // Relative
+      assertHasRoot('hello', false)
+      assertHasRoot('hello/world', false)
+      assertHasRoot('hello\\world', false)
+    }
+  })
+
+  it('normalizeSeparators normalizes slashes', () => {
+    if (IS_WINDOWS) {
+      // Drive-rooted
+      assertNormalizeSeparators('C:/', 'C:\\')
+      assertNormalizeSeparators('C:/hello', 'C:\\hello')
+      assertNormalizeSeparators('C:/hello/', 'C:\\hello\\')
+      assertNormalizeSeparators('C:\\', 'C:\\')
+      assertNormalizeSeparators('C:\\hello', 'C:\\hello')
+      assertNormalizeSeparators('C:', 'C:')
+      assertNormalizeSeparators('C:hello', 'C:hello')
+      assertNormalizeSeparators('C:hello/world', 'C:hello\\world')
+      assertNormalizeSeparators('C:hello\\world', 'C:hello\\world')
+      assertNormalizeSeparators('/', '\\')
+      assertNormalizeSeparators('/hello', '\\hello')
+      assertNormalizeSeparators('/hello/world', '\\hello\\world')
+      assertNormalizeSeparators('/hello//world', '\\hello\\world')
+      assertNormalizeSeparators('\\', '\\')
+      assertNormalizeSeparators('\\hello', '\\hello')
+      assertNormalizeSeparators('\\hello\\', '\\hello\\')
+      assertNormalizeSeparators('\\hello\\world', '\\hello\\world')
+      assertNormalizeSeparators('\\hello\\\\world', '\\hello\\world')
+
+      // UNC
+      assertNormalizeSeparators('//machine/share', '\\\\machine\\share')
+      assertNormalizeSeparators('//machine/share/', '\\\\machine\\share\\')
+      assertNormalizeSeparators(
+        '//machine/share/hello',
+        '\\\\machine\\share\\hello'
+      )
+      assertNormalizeSeparators('///machine/share', '\\\\machine\\share')
+      assertNormalizeSeparators('\\\\machine\\share', '\\\\machine\\share')
+      assertNormalizeSeparators('\\\\machine\\share\\', '\\\\machine\\share\\')
+      assertNormalizeSeparators(
+        '\\\\machine\\share\\hello',
+        '\\\\machine\\share\\hello'
+      )
+      assertNormalizeSeparators('\\\\\\machine\\share', '\\\\machine\\share')
+
+      // Relative
+      assertNormalizeSeparators('hello', 'hello')
+      assertNormalizeSeparators('hello/world', 'hello\\world')
+      assertNormalizeSeparators('hello//world', 'hello\\world')
+      assertNormalizeSeparators('hello\\world', 'hello\\world')
+      assertNormalizeSeparators('hello\\\\world', 'hello\\world')
+    } else {
+      // Rooted
+      assertNormalizeSeparators('/', '/')
+      assertNormalizeSeparators('/hello', '/hello')
+      assertNormalizeSeparators('/hello/world', '/hello/world')
+      assertNormalizeSeparators('//hello/world/', '/hello/world/')
+
+      // Backslash not converted
+      assertNormalizeSeparators('C:\\', 'C:\\')
+      assertNormalizeSeparators('C:\\\\hello\\\\', 'C:\\\\hello\\\\')
+      assertNormalizeSeparators('\\', '\\')
+      assertNormalizeSeparators('\\hello', '\\hello')
+      assertNormalizeSeparators('\\hello\\world', '\\hello\\world')
+      assertNormalizeSeparators('hello\\world', 'hello\\world')
+
+      // UNC not converted
+      assertNormalizeSeparators('\\\\machine\\share', '\\\\machine\\share')
+
+      // UNC not preserved
+      assertNormalizeSeparators('//machine/share', '/machine/share')
+
+      // Relative
+      assertNormalizeSeparators('hello', 'hello')
+      assertNormalizeSeparators('hello/////world', 'hello/world')
+    }
+  })
+
+  it('safeTrimTrailingSeparator safely trims trailing separator', () => {
+    assertSafeTrimTrailingSeparator('', '')
+
+    if (IS_WINDOWS) {
+      // Removes redundant slashes
+      assertSafeTrimTrailingSeparator(
+        'C:\\\\hello\\\\\\world\\\\',
+        'C:\\hello\\world'
+      )
+      assertSafeTrimTrailingSeparator('C://hello///world//', 'C:\\hello\\world')
+      // Relative root:
+      assertSafeTrimTrailingSeparator(
+        '\\hello\\\\world\\\\again\\\\',
+        '\\hello\\world\\again'
+      )
+      assertSafeTrimTrailingSeparator(
+        '/hello///world//again//',
+        '\\hello\\world\\again'
+      )
+      // UNC:
+      assertSafeTrimTrailingSeparator('\\\\hello\\world\\', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator(
+        '\\\\hello\\world\\\\',
+        '\\\\hello\\world'
+      )
+      assertSafeTrimTrailingSeparator(
+        '\\\\hello\\\\\\world\\\\again\\',
+        '\\\\hello\\world\\again'
+      )
+      assertSafeTrimTrailingSeparator('//hello/world/', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator('//hello/world//', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator(
+        '//hello//world//again/',
+        '\\\\hello\\world\\again'
+      )
+      // Relative:
+      assertSafeTrimTrailingSeparator('hello\\world\\', 'hello\\world')
+
+      // Slash trimming
+      assertSafeTrimTrailingSeparator('a:/hello/', 'a:\\hello')
+      assertSafeTrimTrailingSeparator('z:/hello', 'z:\\hello')
+      assertSafeTrimTrailingSeparator('C:/', 'C:\\')
+      assertSafeTrimTrailingSeparator('C:\\', 'C:\\')
+      assertSafeTrimTrailingSeparator('C:/hello/world', 'C:\\hello\\world')
+      assertSafeTrimTrailingSeparator('C:/hello/world/', 'C:\\hello\\world')
+      assertSafeTrimTrailingSeparator('C:', 'C:')
+      assertSafeTrimTrailingSeparator('C:hello/', 'C:hello')
+      assertSafeTrimTrailingSeparator('/', '\\')
+      assertSafeTrimTrailingSeparator('/hello/', '\\hello')
+      assertSafeTrimTrailingSeparator('\\', '\\')
+      assertSafeTrimTrailingSeparator('\\hello\\', '\\hello')
+      assertSafeTrimTrailingSeparator('//hello/', '\\\\hello')
+      assertSafeTrimTrailingSeparator('//hello/world', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator('//hello/world/', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator('\\\\hello', '\\\\hello')
+      assertSafeTrimTrailingSeparator('\\\\hello\\', '\\\\hello')
+      assertSafeTrimTrailingSeparator('\\\\hello\\world', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator('\\\\hello\\world\\', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator('hello/world/', 'hello\\world')
+      assertSafeTrimTrailingSeparator('hello/', 'hello')
+      assertSafeTrimTrailingSeparator('../../', '..\\..')
+    } else {
+      // Should not converts slashes
+      assertSafeTrimTrailingSeparator('/hello\\world', '/hello\\world')
+      assertSafeTrimTrailingSeparator('/hello\\world/', '/hello\\world')
+      assertSafeTrimTrailingSeparator('\\\\hello\\world/', '\\\\hello\\world')
+      assertSafeTrimTrailingSeparator('hello\\world/', 'hello\\world')
+
+      // Should remove redundant slashes (rooted paths; UNC format not special)
+      assertSafeTrimTrailingSeparator('//hello', '/hello')
+      assertSafeTrimTrailingSeparator('//hello/world', '/hello/world')
+      assertSafeTrimTrailingSeparator('//hello/world/', '/hello/world')
+      assertSafeTrimTrailingSeparator('//hello//world//', '/hello/world')
+      assertSafeTrimTrailingSeparator('///hello////world///', '/hello/world')
+
+      // Should remove redundant slashes (relative paths)
+      assertSafeTrimTrailingSeparator('hello//world//', 'hello/world')
+      assertSafeTrimTrailingSeparator('hello///world///', 'hello/world')
+
+      // Slash trimming (Windows drive root format not special)
+      assertSafeTrimTrailingSeparator('C:/', 'C:')
+      assertSafeTrimTrailingSeparator('C:/hello', 'C:/hello')
+      assertSafeTrimTrailingSeparator('C:/hello/', 'C:/hello')
+      assertSafeTrimTrailingSeparator('C:hello/', 'C:hello')
+
+      // Slash trimming (rooted paths)
+      assertSafeTrimTrailingSeparator('/', '/')
+      assertSafeTrimTrailingSeparator('/hello', '/hello')
+      assertSafeTrimTrailingSeparator('/hello/', '/hello')
+      assertSafeTrimTrailingSeparator('/hello/world/', '/hello/world')
+
+      // Slash trimming (relative paths)
+      assertSafeTrimTrailingSeparator('hello/world/', 'hello/world')
+      assertSafeTrimTrailingSeparator('../../', '../..')
+    }
+  })
+})
+
+function assertDirectoryName(itemPath: string, expected: string): void {
+  expect(pathHelper.dirname(itemPath)).toBe(expected)
+}
+
+function assertEnsureAbsoluteRoot(
+  root: string,
+  itemPath: string,
+  expected: string
+): void {
+  expect(pathHelper.ensureAbsoluteRoot(root, itemPath)).toBe(expected)
+}
+
+function assertHasAbsoluteRoot(itemPath: string, expected: boolean): void {
+  expect(pathHelper.hasAbsoluteRoot(itemPath)).toBe(expected)
+}
+
+function assertHasRoot(itemPath: string, expected: boolean): void {
+  expect(pathHelper.hasRoot(itemPath)).toBe(expected)
+}
+
+function assertNormalizeSeparators(itemPath: string, expected: string): void {
+  expect(pathHelper.normalizeSeparators(itemPath)).toBe(expected)
+}
+
+function assertSafeTrimTrailingSeparator(
+  itemPath: string,
+  expected: string
+): void {
+  expect(pathHelper.safeTrimTrailingSeparator(itemPath)).toBe(expected)
+}
diff --git a/packages/glob/__tests__/internal-path.test.ts b/packages/glob/__tests__/internal-path.test.ts
new file mode 100644
index 0000000..37090e4
--- /dev/null
+++ b/packages/glob/__tests__/internal-path.test.ts
@@ -0,0 +1,92 @@
+import * as path from 'path'
+import {Path} from '../src/internal-path'
+
+const IS_WINDOWS = process.platform === 'win32'
+
+describe('path', () => {
+  it('constructs from rooted path', () => {
+    assertPath(`/`, `${path.sep}`, [path.sep])
+    assertPath(`/foo`, `${path.sep}foo`, [path.sep, 'foo'])
+    if (IS_WINDOWS) {
+      assertPath(`C:\\foo`, `C:\\foo`, ['C:\\', 'foo'])
+      assertPath(`C:foo`, `C:foo`, ['C:', 'foo'])
+      assertPath(`\\\\foo\\bar\\baz`, `\\\\foo\\bar\\baz`, [
+        '\\\\foo\\bar',
+        'baz'
+      ])
+    }
+  })
+
+  it('constructs from rooted segments', () => {
+    assertPath([`/`], `${path.sep}`, [path.sep])
+    assertPath([`/`, `foo`], `${path.sep}foo`, [path.sep, 'foo'])
+    if (IS_WINDOWS) {
+      assertPath([`C:\\`, `foo`], `C:\\foo`, ['C:\\', 'foo'])
+      assertPath([`C:`, `foo`], `C:foo`, ['C:', 'foo'])
+      assertPath([`\\\\foo\\bar`, `baz`], `\\\\foo\\bar\\baz`, [
+        '\\\\foo\\bar',
+        'baz'
+      ])
+    }
+  })
+
+  it('constructs from relative path', () => {
+    assertPath(`foo`, `foo`, ['foo'])
+    assertPath(`foo/bar`, `foo${path.sep}bar`, ['foo', 'bar'])
+  })
+
+  it('constructs from relative segments', () => {
+    assertPath([`foo`], `foo`, ['foo'])
+    assertPath([`foo`, `bar`], `foo${path.sep}bar`, ['foo', 'bar'])
+  })
+
+  it('normalizes slashes', () => {
+    assertPath(
+      `/foo///bar${path.sep}${path.sep}${path.sep}baz`,
+      `${path.sep}foo${path.sep}bar${path.sep}baz`,
+      [path.sep, 'foo', 'bar', 'baz']
+    )
+  })
+
+  it('preserves relative pathing', () => {
+    assertPath(
+      '/foo/../bar/./baz',
+      `${path.sep}foo${path.sep}..${path.sep}bar${path.sep}.${path.sep}baz`,
+      [path.sep, 'foo', '..', 'bar', '.', 'baz']
+    )
+  })
+
+  it('trims unnecessary trailing slash', () => {
+    assertPath('/', path.sep, [path.sep])
+    assertPath('/foo/', `${path.sep}foo`, [path.sep, 'foo'])
+    assertPath('foo/', 'foo', ['foo'])
+    assertPath('foo/bar/', `foo${path.sep}bar`, ['foo', 'bar'])
+    if (IS_WINDOWS) {
+      assertPath('\\', '\\', ['\\'])
+      assertPath('C:\\', 'C:\\', ['C:\\'])
+      assertPath('C:\\foo\\', 'C:\\foo', ['C:\\', 'foo'])
+      assertPath('C:foo\\', 'C:foo', ['C:', 'foo'])
+      assertPath('\\\\computer\\share\\', '\\\\computer\\share', [
+        '\\\\computer\\share'
+      ])
+      assertPath('\\\\computer\\share\\foo', '\\\\computer\\share\\foo', [
+        '\\\\computer\\share',
+        'foo'
+      ])
+      assertPath('\\\\computer\\share\\foo\\', '\\\\computer\\share\\foo', [
+        '\\\\computer\\share',
+        'foo'
+      ])
+    }
+  })
+})
+
+function assertPath(
+  itemPath: string | string[],
+  expectedPath: string,
+  expectedSegments: string[]
+): void {
+  const actual = new Path(itemPath)
+  expect(actual.toString()).toBe(expectedPath)
+  expect(actual.segments).toEqual(expectedSegments)
+}
diff --git a/packages/glob/__tests__/internal-pattern-helper.test.ts b/packages/glob/__tests__/internal-pattern-helper.test.ts
new file mode 100644
index 0000000..dc8b0de
--- /dev/null
+++ b/packages/glob/__tests__/internal-pattern-helper.test.ts
@@ -0,0 +1,190 @@
+import * as path from 'path'
+import * as patternHelper from '../src/internal-pattern-helper'
+import {MatchKind} from '../src/internal-match-kind'
+import {IS_WINDOWS} from '../../io/src/io-util'
+
+describe('pattern-helper', () => {
+  it('getSearchPaths omits negate search paths', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const patterns = patternHelper.parse(
+      [
+        `${root}search1/foo/**`,
+        `${root}search2/bar/**`,
+        `!${root}search3/baz/**`
+      ],
+      patternHelper.getOptions()
+    )
+    const searchPaths = patternHelper.getSearchPaths(patterns)
+    expect(searchPaths).toEqual([
+      `${root}search1${path.sep}foo`,
+      `${root}search2${path.sep}bar`
+    ])
+  })
+
+  it('getSearchPaths omits search path when ancestor is also a search path', () => {
+    if (IS_WINDOWS) {
+      const patterns = patternHelper.parse(
+        [
+          'C:\\Search1\\Foo\\**',
+          'C:\\sEARCH1\\fOO\\bar\\**',
+          'C:\\sEARCH1\\foo\\bar',
+          'C:\\Search2\\**',
+          'C:\\Search3\\Foo\\Bar\\**',
+          'C:\\sEARCH3\\fOO\\bAR\\**'
+        ],
+        patternHelper.getOptions()
+      )
+      const searchPaths = patternHelper.getSearchPaths(patterns)
+      expect(searchPaths).toEqual([
+        'C:\\Search1\\Foo',
+        'C:\\Search2',
+        'C:\\Search3\\Foo\\Bar'
+      ])
+    } else {
+      const patterns = patternHelper.parse(
+        [
+          '/search1/foo/**',
+          '/search1/foo/bar/**',
+          '/search2/foo/bar',
+          '/search2/**',
+          '/search3/foo/bar/**',
+          '/search3/foo/bar/**'
+        ],
+        patternHelper.getOptions()
+      )
+      const searchPaths = patternHelper.getSearchPaths(patterns)
+      expect(searchPaths).toEqual([
+        '/search1/foo',
+        '/search2',
+        '/search3/foo/bar'
+      ])
+    }
+  })
+
+  it('match supports interleaved exclude patterns', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const itemPaths = [
+      `${root}solution1/proj1/proj1.proj`,
+      `${root}solution1/proj1/README.txt`,
+      `${root}solution1/proj2/proj2.proj`,
+      `${root}solution1/proj2/README.txt`,
+      `${root}solution1/solution1.sln`,
+      `${root}solution2/proj1/proj1.proj`,
+      `${root}solution2/proj1/README.txt`,
+      `${root}solution2/proj2/proj2.proj`,
+      `${root}solution2/proj2/README.txt`,
+      `${root}solution2/solution2.sln`
+    ]
+    const patterns = patternHelper.parse(
+      [
+        `${root}**/*.proj`, // include all proj files
+        `${root}**/README.txt`, // include all README files
+        `!${root}**/solution2/**`, // exclude the solution 2 folder entirely
+        `${root}**/*.sln`, // include all sln files
+        `!${root}**/proj2/README.txt` // exclude proj2 README files
+      ],
+      patternHelper.getOptions({implicitDescendants: false})
+    )
+    const matched = itemPaths.filter(
+      x => patternHelper.match(patterns, x) === MatchKind.All
+    )
+    expect(matched).toEqual([
+      `${root}solution1/proj1/proj1.proj`,
+      `${root}solution1/proj1/README.txt`,
+      `${root}solution1/proj2/proj2.proj`,
+      `${root}solution1/solution1.sln`,
+      `${root}solution2/solution2.sln`
+    ])
+  })
+
+  it('match supports excluding directories', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const itemPaths = [
+      root,
+      `${root}foo`,
+      `${root}foo/bar`,
+      `${root}foo/bar/baz`
+    ]
+    const patterns = patternHelper.parse(
+      [
+        `${root}foo/**`, // include all files and directories
+        `!${root}foo/**/` // exclude directories
+      ],
+      patternHelper.getOptions({implicitDescendants: false})
+    )
+    const matchKinds = itemPaths.map(x => patternHelper.match(patterns, x))
+    expect(matchKinds).toEqual([
+      MatchKind.None,
+      MatchKind.File,
+      MatchKind.File,
+      MatchKind.File
+    ])
+  })
+
+  it('match supports including directories only', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const itemPaths = [
+      root,
+      `${root}foo/`,
+      `${root}foo/bar`,
+      `${root}foo/bar/baz`
+    ]
+    const patterns = patternHelper.parse(
+      [
+        `${root}foo/**/` // include directories only
+      ],
+      patternHelper.getOptions({implicitDescendants: false})
+    )
+    const matchKinds = itemPaths.map(x => patternHelper.match(patterns, x))
+    expect(matchKinds).toEqual([
+      MatchKind.None,
+      MatchKind.Directory,
+      MatchKind.Directory,
+      MatchKind.Directory
+    ])
+  })
+
+  it('parse skips comments', () => {
+    const patterns = patternHelper.parse(
+      ['# comment 1', ' # comment 2', '!#hello-world.txt'],
+      patternHelper.getOptions({implicitDescendants: false})
+    )
+    expect(patterns).toHaveLength(1)
+    expect(patterns[0].negate).toBeTruthy()
+    expect(patterns[0].segments.reverse()[0]).toEqual('#hello-world.txt')
+  })
+
+  it('parse skips empty patterns', () => {
+    const patterns = patternHelper.parse(
+      ['', ' ', 'hello-world.txt'],
+      patternHelper.getOptions({implicitDescendants: false})
+    )
+    expect(patterns).toHaveLength(1)
+    expect(patterns[0].segments.reverse()[0]).toEqual('hello-world.txt')
+  })
+
+  it('partialMatch skips negate patterns', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const patterns = patternHelper.parse(
+      [
+        `${root}search1/foo/**`,
+        `${root}search2/bar/**`,
+        `!${root}search2/bar/**`,
+        `!${root}search3/baz/**`
+      ],
+      patternHelper.getOptions({implicitDescendants: false})
+    )
+    expect(patternHelper.partialMatch(patterns, `${root}search1`)).toBeTruthy()
+    expect(
+      patternHelper.partialMatch(patterns, `${root}search1/foo`)
+    ).toBeTruthy()
+    expect(patternHelper.partialMatch(patterns, `${root}search2`)).toBeTruthy()
+    expect(
+      patternHelper.partialMatch(patterns, `${root}search2/bar`)
+    ).toBeTruthy()
+    expect(patternHelper.partialMatch(patterns, `${root}search3`)).toBeFalsy()
+    expect(
+      patternHelper.partialMatch(patterns, `${root}search3/bar`)
+    ).toBeFalsy()
+  })
+})
diff --git a/packages/glob/__tests__/internal-pattern.test.ts b/packages/glob/__tests__/internal-pattern.test.ts
new file mode 100644
index 0000000..c6a1b2c
--- /dev/null
+++ b/packages/glob/__tests__/internal-pattern.test.ts
@@ -0,0 +1,366 @@
+import * as io from '../../io/src/io'
+import * as path from 'path'
+import {MatchKind} from '../src/internal-match-kind'
+import {promises as fs} from 'fs'
+
+// Mock 'os' before importing Pattern
+/* eslint-disable import/first */
+/* eslint-disable @typescript-eslint/promise-function-async */
+// Note, @typescript-eslint/promise-function-async is a false positive due to the
+// mock factory delegate which returns any. Fixed in a future version of jest.
+jest.mock('os', () => jest.requireActual('os'))
+const os = jest.requireMock('os')
+import {Pattern} from '../src/internal-pattern'
+jest.resetModuleRegistry()
+
+const IS_WINDOWS = process.platform === 'win32'
+
+describe('pattern', () => {
+  beforeAll(async () => {
+    await io.rmRF(getTestTemp())
+  })
+
+  it('counts leading negate markers', () => {
+    const actual = [
+      '/initial-includes/*.txt',
+      '!!/hello/two-negate-markers.txt',
+      '!!!!/hello/four-negate-markers.txt',
+      '!/initial-includes/one-negate-markers.txt',
+      '!!!/initial-includes/three-negate-markers.txt'
+    ].map(x => new Pattern(x).negate)
+    expect(actual).toEqual([false, false, false, true, true])
+  })
+
+  it('escapes homedir', async () => {
+    const originalHomedir = os.homedir
+    const home = path.join(getTestTemp(), 'home-with-[and]')
+    await fs.mkdir(home, {recursive: true})
+    try {
+      os.homedir = () => home
+      const pattern = new Pattern('~/m*')
+
+      expect(pattern.searchPath).toBe(home)
+      expect(pattern.match(path.join(home, 'match'))).toBeTruthy()
+      expect(pattern.match(path.join(home, 'not-match'))).toBeFalsy()
+    } finally {
+      os.homedir = originalHomedir
+    }
+  })
+
+  it('escapes root', async () => {
+    const originalCwd = process.cwd()
+    const rootPath = path.join(getTestTemp(), 'cwd-with-[and]')
+    await fs.mkdir(rootPath, {recursive: true})
+    try {
+      process.chdir(rootPath)
+
+      // Relative
+      let pattern = new Pattern('m*')
+      expect(pattern.searchPath).toBe(rootPath)
+      expect(pattern.match(path.join(rootPath, 'match'))).toBeTruthy()
+      expect(pattern.match(path.join(rootPath, 'not-match'))).toBeFalsy()
+
+      if (IS_WINDOWS) {
+        const currentDrive = process.cwd().substr(0, 2)
+        expect(currentDrive.match(/^[A-Z]:$/i)).toBeTruthy()
+
+        // Relative current drive letter, e.g. C:m*
+        pattern = new Pattern(`${currentDrive}m*`)
+        expect(pattern.searchPath).toBe(rootPath)
+        expect(pattern.match(path.join(rootPath, 'match'))).toBeTruthy()
+        expect(pattern.match(path.join(rootPath, 'not-match'))).toBeFalsy()
+
+        // Relative current drive, e.g. \path\to\cwd\m*
+        pattern = new Pattern(
+          `${Pattern.globEscape(process.cwd().substr(2))}\\m*`
+        )
+        expect(pattern.searchPath).toBe(rootPath)
+        expect(pattern.match(path.join(rootPath, 'match'))).toBeTruthy()
+        expect(pattern.match(path.join(rootPath, 'not-match'))).toBeFalsy()
+      }
+    } finally {
+      process.chdir(originalCwd)
+    }
+  })
+
+  it('globstar matches immediately preceeding directory', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const pattern = new Pattern(`${root}foo/bar/**`)
+    const actual = [
+      root,
+      `${root}foo`,
+      `${root}foo/bar`,
+      `${root}foo/bar/baz`
+    ].map(x => pattern.match(x))
+    expect(actual).toEqual([
+      MatchKind.None,
+      MatchKind.None,
+      MatchKind.All,
+      MatchKind.All
+    ])
+  })
+
+  it('is case insensitive match on Windows', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const pattern = new Pattern(`${root}Foo/**/Baz`)
+    expect(pattern.match(`${root}Foo/Baz`)).toBe(MatchKind.All)
+    expect(pattern.match(`${root}Foo/bAZ`)).toBe(
+      IS_WINDOWS ? MatchKind.All : MatchKind.None
+    )
+    expect(pattern.match(`${root}fOO/Baz`)).toBe(
+      IS_WINDOWS ? MatchKind.All : MatchKind.None
+    )
+    expect(pattern.match(`${root}fOO/bar/bAZ`)).toBe(
+      IS_WINDOWS ? MatchKind.All : MatchKind.None
+    )
+  })
+
+  it('is case insensitive partial match on Windows', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const pattern = new Pattern(`${root}Foo/Bar/**/Baz`)
+    expect(pattern.partialMatch(`${root}Foo`)).toBeTruthy()
+    expect(pattern.partialMatch(`${root}fOO`)).toBe(IS_WINDOWS ? true : false)
+  })
+
+  it('matches root', () => {
+    const pattern = new Pattern(IS_WINDOWS ? 'C:\\**' : '/**')
+    expect(pattern.match(IS_WINDOWS ? 'C:\\' : '/')).toBe(MatchKind.All)
+  })
+
+  it('partial matches root', () => {
+    if (IS_WINDOWS) {
+      let pattern = new Pattern('C:\\foo\\**')
+      expect(pattern.partialMatch('c:\\')).toBeTruthy()
+      pattern = new Pattern('c:\\foo\\**')
+      expect(pattern.partialMatch('C:\\')).toBeTruthy()
+    } else {
+      const pattern = new Pattern('/foo/**')
+      expect(pattern.partialMatch('/')).toBeTruthy()
+    }
+  })
+
+  it('replaces leading . segment', () => {
+    // Pattern is '.'
+    let pattern = new Pattern('.')
+    expect(pattern.match(process.cwd())).toBe(MatchKind.All)
+    expect(pattern.match(path.join(process.cwd(), 'foo'))).toBe(MatchKind.None)
+
+    // Pattern is './foo'
+    pattern = new Pattern('./foo')
+    expect(pattern.match(path.join(process.cwd(), 'foo'))).toBe(MatchKind.All)
+    expect(pattern.match(path.join(process.cwd(), 'bar'))).toBe(MatchKind.None)
+
+    // Pattern is '.foo'
+    pattern = new Pattern('.foo')
+    expect(pattern.match(path.join(process.cwd(), '.foo'))).toBe(MatchKind.All)
+    expect(pattern.match(path.join(process.cwd(), 'foo'))).toBe(MatchKind.None)
+    expect(pattern.match(`${process.cwd()}foo`)).toBe(MatchKind.None)
+  })
+
+  it('replaces leading ~ segment', async () => {
+    const homedir = os.homedir()
+    expect(homedir).toBeTruthy()
+    await fs.stat(homedir)
+
+    // Pattern is '~'
+    let pattern = new Pattern('~')
+    expect(pattern.match(homedir)).toBe(MatchKind.All)
+    expect(pattern.match(path.join(homedir, 'foo'))).toBe(MatchKind.None)
+
+    // Pattern is '~/foo'
+    pattern = new Pattern('~/foo')
+    expect(pattern.match(path.join(homedir, 'foo'))).toBe(MatchKind.All)
+    expect(pattern.match(path.join(homedir, 'bar'))).toBe(MatchKind.None)
+
+    // Pattern is '~foo'
+    pattern = new Pattern('~foo')
+    expect(pattern.match(path.join(process.cwd(), '~foo'))).toBe(MatchKind.All)
+    expect(pattern.match(path.join(homedir, 'foo'))).toBe(MatchKind.None)
+    expect(pattern.match(`${homedir}foo`)).toBe(MatchKind.None)
+  })
+
+  it('replaces leading relative root', () => {
+    if (IS_WINDOWS) {
+      const currentDrive = process.cwd().substr(0, 2)
+      expect(currentDrive.match(/^[A-Z]:$/i)).toBeTruthy()
+      const otherDrive = currentDrive.toUpperCase().startsWith('C')
+        ? 'D:'
+        : 'C:'
+      expect(process.cwd().length).toBeGreaterThan(3) // sanity check not drive root
+
+      // Pattern is 'C:'
+      let pattern = new Pattern(currentDrive)
+      expect(pattern.match(process.cwd())).toBeTruthy()
+      expect(pattern.match(path.join(process.cwd(), 'foo'))).toBeFalsy()
+
+      // Pattern is 'C:foo'
+      pattern = new Pattern(`${currentDrive}foo`)
+      expect(pattern.match(path.join(process.cwd(), 'foo'))).toBeTruthy()
+      expect(pattern.match(path.join(process.cwd(), 'bar'))).toBeFalsy()
+      expect(pattern.match(`${currentDrive}\\foo`)).toBeFalsy()
+
+      // Pattern is 'X:'
+      pattern = new Pattern(otherDrive)
+      expect(pattern.match(`${otherDrive}\\`)).toBeTruthy()
+      expect(pattern.match(`${otherDrive}\\foo`)).toBeFalsy()
+
+      // Pattern is 'X:foo'
+      pattern = new Pattern(`${otherDrive}foo`)
+      expect(pattern.match(`${otherDrive}\\foo`)).toBeTruthy()
+      expect(pattern.match(`${otherDrive}\\bar`)).toBeFalsy()
+
+      // Pattern is '\\path\\to\\cwd'
+      pattern = new Pattern(`${process.cwd().substr(2)}\\foo`)
+      expect(pattern.match(path.join(process.cwd(), 'foo'))).toBeTruthy()
+      expect(pattern.match(path.join(process.cwd(), 'bar'))).toBeFalsy()
+    }
+  })
+
+  it('roots exclude pattern', () => {
+    const patternStrings = ['!hello.txt', '!**/world.txt']
+    const actual = patternStrings.map(x => new Pattern(x))
+    const expected = patternStrings
+      .map(x => x.substr(1))
+      .map(x => path.join(Pattern.globEscape(process.cwd()), x))
+      .map(x => `!${x}`)
+      .map(x => new Pattern(x))
+    expect(actual.map(x => x.negate)).toEqual([true, true])
+    expect(actual.map(x => x.segments)).toEqual(expected.map(x => x.segments))
+  })
+
+  it('roots include pattern', () => {
+    const patternStrings = ['hello.txt', '**/world.txt']
+    const actual = patternStrings.map(x => new Pattern(x))
+    const expected = patternStrings.map(
+      x => new Pattern(path.join(Pattern.globEscape(process.cwd()), x))
+    )
+    expect(actual.map(x => x.segments)).toEqual(expected.map(x => x.segments))
+  })
+
+  it('sets trailing separator', () => {
+    expect(new Pattern(' foo ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' /foo ').trailingSeparator).toBeFalsy()
+    expect(new Pattern('! /foo ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' /foo/* ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' /foo/** ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' \\foo ').trailingSeparator).toBeFalsy()
+    expect(new Pattern('! \\foo ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' \\foo\\* ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' \\foo\\** ').trailingSeparator).toBeFalsy()
+    expect(new Pattern(' foo/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' /foo/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' C:/foo/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' C:foo/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' D:foo/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern('! /foo/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' /foo/*/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' /foo/**/ ').trailingSeparator).toBeTruthy()
+    expect(new Pattern(' foo\\ ').trailingSeparator).toEqual(
+      IS_WINDOWS ? true : false
+    )
+    expect(new Pattern(' \\foo\\ ').trailingSeparator).toEqual(
+      IS_WINDOWS ? true : false
+    )
+    expect(new Pattern('! \\foo\\ ').trailingSeparator).toEqual(
+      IS_WINDOWS ? true : false
+    )
+    expect(new Pattern(' \\foo\\*\\ ').trailingSeparator).toEqual(
+      IS_WINDOWS ? true : false
+    )
+    expect(new Pattern(' \\foo\\**\\ ').trailingSeparator).toEqual(
+      IS_WINDOWS ? true : false
+    )
+  })
+
+  it('supports including directories only', () => {
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    const pattern = new Pattern(`${root}foo/**/`) // trailing slash
+    const actual = [
+      root,
+      `${root}foo/`,
+      `${root}foo/bar`,
+      `${root}foo/bar/baz`
+    ].map(x => pattern.match(x))
+    expect(pattern.trailingSeparator).toBeTruthy()
+    expect(actual).toEqual([
+      MatchKind.None,
+      MatchKind.Directory,
+      MatchKind.Directory,
+      MatchKind.Directory
+    ])
+  })
+
+  it('trims pattern', () => {
+    const pattern = new Pattern(' hello.txt ')
+    expect(pattern.segments.reverse()[0]).toBe('hello.txt')
+  })
+
+  it('trims whitespace after trimming negate markers', () => {
+    const pattern = new Pattern(' ! ! ! hello.txt ')
+    expect(pattern.negate).toBeTruthy()
+    expect(pattern.segments.reverse()[0]).toBe('hello.txt')
+  })
+
+  it('unescapes segments to narrow search path', () => {
+    // Positive
+    const root = IS_WINDOWS ? 'C:\\' : '/'
+    let pattern = new Pattern(`${root}foo/b[a]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}bar`)
+    expect(pattern.match(`${root}foo/bar/baz`)).toBeTruthy()
+    pattern = new Pattern(`${root}foo/b[*]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b*r`)
+    expect(pattern.match(`${root}foo/b*r/baz`)).toBeTruthy()
+    expect(pattern.match(`${root}foo/bar/baz`)).toBeFalsy()
+    pattern = new Pattern(`${root}foo/b[?]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b?r`)
+    expect(pattern.match(`${root}foo/b?r/baz`)).toBeTruthy()
+    expect(pattern.match(`${root}foo/bar/baz`)).toBeFalsy()
+    pattern = new Pattern(`${root}foo/b[!]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b!r`)
+    expect(pattern.match(`${root}foo/b!r/baz`)).toBeTruthy()
+    pattern = new Pattern(`${root}foo/b[[]ar/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b[ar`)
+    expect(pattern.match(`${root}foo/b[ar/baz`)).toBeTruthy()
+    pattern = new Pattern(`${root}foo/b[]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b[]r`)
+    expect(pattern.match(`${root}foo/b[]r/baz`)).toBeTruthy()
+    pattern = new Pattern(`${root}foo/b[r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b[r`)
+    expect(pattern.match(`${root}foo/b[r/baz`)).toBeTruthy()
+    pattern = new Pattern(`${root}foo/b]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo${path.sep}b]r`)
+    expect(pattern.match(`${root}foo/b]r/baz`)).toBeTruthy()
+    if (!IS_WINDOWS) {
+      pattern = new Pattern('/foo/b\\[a]r/b*')
+      expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}b[a]r`)
+      expect(pattern.match('/foo/b[a]r/baz')).toBeTruthy()
+      pattern = new Pattern('/foo/b[\\!]r/b*')
+      expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}b!r`)
+      expect(pattern.match('/foo/b!r/baz')).toBeTruthy()
+      pattern = new Pattern('/foo/b[\\]]r/b*')
+      expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}b]r`)
+      expect(pattern.match('/foo/b]r/baz')).toBeTruthy()
+      pattern = new Pattern('/foo/b[\\a]r/b*')
+      expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}bar`)
+      expect(pattern.match('/foo/bar/baz')).toBeTruthy()
+    }
+
+    // Negative
+    pattern = new Pattern(`${root}foo/b[aA]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo`)
+    pattern = new Pattern(`${root}foo/b[!a]r/b*`)
+    expect(pattern.searchPath).toBe(`${root}foo`)
+    if (IS_WINDOWS) {
+      pattern = new Pattern('C:/foo/b\\[a]r/b*')
+      expect(pattern.searchPath).toBe(`C:\\foo\\b\\ar`)
+      expect(pattern.match('C:/foo/b/ar/baz')).toBeTruthy()
+      pattern = new Pattern('C:/foo/b[\\!]r/b*')
+      expect(pattern.searchPath).toBe('C:\\foo\\b[\\!]r')
+      expect(pattern.match('C:/foo/b[undefined/!]r/baz')).toBeTruthy() // Note, "undefined" substr to accommodate a bug in Minimatch when nocase=true
+    }
+  })
+})
+
+function getTestTemp(): string {
+  return path.join(__dirname, '_temp', 'internal-pattern')
+}