async function _checkout({
  fs,
  cache,
  onProgress,
  onPostCheckout,
  dir,
  gitdir,
  remote,
  ref,
  filepaths,
  noCheckout,
  noUpdateHead,
  dryRun,
  force,
  track = true,
}) {
  // oldOid is defined only if onPostCheckout hook is attached
  let oldOid
  if (onPostCheckout) {
    try {
      oldOid = await GitRefManager.resolve({ fs, gitdir, ref: 'HEAD' })
    } catch (err) {
      oldOid = '0000000000000000000000000000000000000000'
    }
  }

  // Get tree oid
  let oid
  try {
    oid = await GitRefManager.resolve({ fs, gitdir, ref })
    // TODO: Figure out what to do if both 'ref' and 'remote' are specified, ref already exists,
    // and is configured to track a different remote.
  } catch (err) {
    if (ref === 'HEAD') throw err
    // If `ref` doesn't exist, create a new remote tracking branch
    // Figure out the commit to checkout
    const remoteRef = `${remote}/${ref}`
    oid = await GitRefManager.resolve({
      fs,
      gitdir,
      ref: remoteRef,
    })
    if (track) {
      // Set up remote tracking branch
      const config = await GitConfigManager.get({ fs, gitdir })
      await config.set(`branch.${ref}.remote`, remote)
      await config.set(`branch.${ref}.merge`, `refs/heads/${ref}`)
      await GitConfigManager.save({ fs, gitdir, config })
    }
    // Create a new branch that points at that same commit
    await GitRefManager.writeRef({
      fs,
      gitdir,
      ref: `refs/heads/${ref}`,
      value: oid,
    })
  }

  // Update working dir
  if (!noCheckout) {
    let ops
    // First pass - just analyze files (not directories) and figure out what needs to be done
    try {
      ops = await analyze({
        fs,
        cache,
        onProgress,
        dir,
        gitdir,
        ref,
        force,
        filepaths,
      })
    } catch (err) {
      // Throw a more helpful error message for this common mistake.
      if (err instanceof NotFoundError && err.data.what === oid) {
        throw new CommitNotFetchedError(ref, oid)
      } else {
        throw err
      }
    }

    // Report conflicts
    const conflicts = ops
      .filter(([method]) => method === 'conflict')
      .map(([method, fullpath]) => fullpath)
    if (conflicts.length > 0) {
      throw new CheckoutConflictError(conflicts)
    }

    // Collect errors
    const errors = ops
      .filter(([method]) => method === 'error')
      .map(([method, fullpath]) => fullpath)
    if (errors.length > 0) {
      throw new InternalError(errors.join(', '))
    }

    if (dryRun) {
      // Since the format of 'ops' is in flux, I really would rather folk besides myself not start relying on it
      // return ops

      if (onPostCheckout) {
        await onPostCheckout({
          previousHead: oldOid,
          newHead: oid,
          type: filepaths != null && filepaths.length > 0 ? 'file' : 'branch',
        })
      }
      return
    }

    // Second pass - execute planned changes
    // The cheapest semi-parallel solution without computing a full dependency graph will be
    // to just do ops in 4 dumb phases: delete files, delete dirs, create dirs, write files

    let count = 0
    const total = ops.length
    await GitIndexManager.acquire({ fs, gitdir, cache }, async function(index) {
      await Promise.all(
        ops
          .filter(
            ([method]) => method === 'delete' || method === 'delete-index'
          )
          .map(async function([method, fullpath]) {
            const filepath = `${dir}/${fullpath}`
            if (method === 'delete') {
              await fs.rm(filepath)
            }
            index.delete({ filepath: fullpath })
            if (onProgress) {
              await onProgress({
                phase: 'Updating workdir',
                loaded: ++count,
                total,
              })
            }
          })
      )
    })

    // Note: this is cannot be done naively in parallel
    await GitIndexManager.acquire({ fs, gitdir, cache }, async function(index) {
      for (const [method, fullpath] of ops) {
        if (method === 'rmdir' || method === 'rmdir-index') {
          const filepath = `${dir}/${fullpath}`
          try {
            if (method === 'rmdir-index') {
              index.delete({ filepath: fullpath })
            }
            await fs.rmdir(filepath)
            if (onProgress) {
              await onProgress({
                phase: 'Updating workdir',
                loaded: ++count,
                total,
              })
            }
          } catch (e) {
            if (e.code === 'ENOTEMPTY') {
              console.log(
                `Did not delete ${fullpath} because directory is not empty`
              )
            } else {
              throw e
            }
          }
        }
      }
    })

    await Promise.all(
      ops
        .filter(([method]) => method === 'mkdir' || method === 'mkdir-index')
        .map(async function([_, fullpath]) {
          const filepath = `${dir}/${fullpath}`
          await fs.mkdir(filepath)
          if (onProgress) {
            await onProgress({
              phase: 'Updating workdir',
              loaded: ++count,
              total,
            })
          }
        })
    )

    await GitIndexManager.acquire({ fs, gitdir, cache }, async function(index) {
      await Promise.all(
        ops
          .filter(
            ([method]) =>
              method === 'create' ||
              method === 'create-index' ||
              method === 'update' ||
              method === 'mkdir-index'
          )
          .map(async function([method, fullpath, oid, mode, chmod]) {
            const filepath = `${dir}/${fullpath}`
            try {
              if (method !== 'create-index' && method !== 'mkdir-index') {
                const { object } = await readObject({ fs, cache, gitdir, oid })
                if (chmod) {
                  // Note: the mode option of fs.write only works when creating files,
                  // not updating them. Since the `fs` plugin doesn't expose `chmod` this
                  // is our only option.
                  await fs.rm(filepath)
                }
                if (mode === 0o100644) {
                  // regular file
                  await fs.write(filepath, object)
                } else if (mode === 0o100755) {
                  // executable file
                  await fs.write(filepath, object, { mode: 0o777 })
                } else if (mode === 0o120000) {
                  // symlink
                  await fs.writelink(filepath, object)
                } else {
                  throw new InternalError(
                    `Invalid mode 0o${mode.toString(8)} detected in blob ${oid}`
                  )
                }
              }

              const stats = await fs.lstat(filepath)
              // We can't trust the executable bit returned by lstat on Windows,
              // so we need to preserve this value from the TREE.
              // TODO: Figure out how git handles this internally.
              if (mode === 0o100755) {
                stats.mode = 0o755
              }
              // Submodules are present in the git index but use a unique mode different from trees
              if (method === 'mkdir-index') {
                stats.mode = 0o160000
              }
              index.insert({
                filepath: fullpath,
                stats,
                oid,
              })
              if (onProgress) {
                await onProgress({
                  phase: 'Updating workdir',
                  loaded: ++count,
                  total,
                })
              }
            } catch (e) {
              console.log(e)
            }
          })
      )
    })

    if (onPostCheckout) {
      await onPostCheckout({
        previousHead: oldOid,
        newHead: oid,
        type: filepaths != null && filepaths.length > 0 ? 'file' : 'branch',
      })
    }
  }

  // Update HEAD
  if (!noUpdateHead) {
    const fullRef = await GitRefManager.expand({ fs, gitdir, ref })
    if (fullRef.startsWith('refs/heads')) {
      await GitRefManager.writeSymbolicRef({
        fs,
        gitdir,
        ref: 'HEAD',
        value: fullRef,
      })
    } else {
      // detached head
      await GitRefManager.writeRef({ fs, gitdir, ref: 'HEAD', value: oid })
    }
  }
}
@param {object} args
@param {import('../models/FileSystem.js').FileSystem} args.fs
@param {any} args.cache
@param {ProgressCallback} [args.onProgress]
@param {PostCheckoutCallback} [args.onPostCheckout]
@param {string} args.dir
@param {string} args.gitdir
@param {string} args.ref
@param {string[]} [args.filepaths]
@param {string} args.remote
@param {boolean} args.noCheckout
@param {boolean} [args.noUpdateHead]
@param {boolean} [args.dryRun]
@param {boolean} [args.force]
@param {boolean} [args.track]

@returns {Promise<void>} Resolves successfully when filesystem operations are complete