cozy-labs/cozy-desktop

View on GitHub
core/ignore.js

Summary

Maintainability
A
3 hrs
Test Coverage
/** Ignored files/directories handling.
 *
 * Cozy-desktop can ignore some files and folders with a `.cozyignore` file. This
 * file is read only at the startup of Cozy-desktop. So, if this file is
 * modified, cozy-desktop has to be relaunched for the changes to be effective.
 *
 * There 4 places where ignoring files and folders can have a meaning:
 *
 * - when a change is detected on the local file system and cozy-desktop is going
 *   to save it in its internal pouchdb
 * - when a change is detected on the remote cozy and cozy-desktop is going to
 *   save it in its internal pouchdb
 * - when a change is taken from the pouchdb and cozy-desktop is going to apply
 *   on the local file system
 * - when a change is taken from the pouchdb and cozy-desktop is going to apply
 *   on the remote cozy.
 *
 * Even with the first two checks, pouchdb can have a change for an ignored file
 * from a previous run of cozy-desktop where the file was not yet ignored. So, we
 * have to implement the last two checks. It is enough for a file created on one
 * side (local or remote) won't be replicated on the other side if it is ignored.
 *
 * But, there is a special case: conflicts are treated ahead of pouchdb. So, if a
 * file is created in both the local file system and the remote cozy (with
 * different contents) is ignored, the conflict will still be resolved by
 * renaming if we implement only the last two checks. We have to avoid that by
 * also implementing at least one of the first two checks.
 *
 * In practice, it's really convenient to let the changes from the remote couchdb
 * flows to pouchdb, even for ignored files, as it is very costly to find them
 * later if `.cozyignore` has changed. And it's a lot easier to detect local
 * files that were ignored but are no longer at the startup, as cozy-desktop
 * already does a full scan of the local file system at that moment.
 *
 * Thus, cozy-desktop has a check for ignored files and folder in three of the
 * four relevant places:
 *
 * - when a change is detected on the local file system and cozy-desktop is going
 *   to save it in its internal pouchdb
 * - when a change is taken from the pouchdb and cozy-desktop is going to apply
 *   on the local file system
 * - when a change is taken from the pouchdb and cozy-desktop is going to apply
 *   on the remote cozy.
 *
 * @module core/ignore
 * @see https://git-scm.com/docs/gitignore/#_pattern_format
 * @flow
 */

const { basename, dirname, resolve } = require('path')
const { matcher } = require('micromatch')
const fs = require('fs')

const logger = require('./utils/logger')

const log = logger({
  component: 'Ignore'
})

/*::
export type IgnorePattern = {
  match: (string) => boolean,
  basename: boolean,
  folder: boolean,
  negate: boolean
}
*/

/** Load both given file rules & default ones */
function loadSync(rulesFilePath /*: string */) /*: Ignore */ {
  let ignored
  try {
    ignored = readLinesSync(rulesFilePath)
  } catch (err) {
    if (err.code === 'ENOENT') {
      log.info(
        { rulesFilePath },
        'Skip loading of non-existent ignore rules file'
      )
    } else {
      log.warn({ rulesFilePath, err }, 'Failed loading ignore rules file')
    }
    ignored = []
  }
  return new Ignore(ignored).addDefaultRules()
}

/** Read lines from a file.
 */
function readLinesSync(filePath /*: string */) /*: string[] */ {
  return fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
}

// Parse a line and build the corresponding pattern
function buildPattern(line /*: string */) /*: IgnorePattern */ {
  let folder = false
  let negate = false
  let noslash = line.indexOf('/') === -1
  if (line.indexOf('**') !== -1) {
    // Detect two asterisks
    noslash = false
  }
  if (line[0] === '!') {
    // Detect bang prefix
    line = line.slice(1)
    negate = true
  }
  if (line[0] === '/') {
    // Detect leading slash
    line = line.slice(1)
  }
  if (line[line.length - 1] === '/') {
    // Detect trailing slash
    line = line.slice(0, line.length - 1)
    folder = true
  }
  line = line.replace(/^\\/, '') // Remove leading escaping char
  line = line.replace(/( |\t)*$/, '') // Remove trailing spaces
  // Ignore case for case insensitive file-systems
  const nocase = process.platform === 'darwin' || process.platform === 'win32'
  const pattern = {
    match: matcher(line, { nocase }),
    basename: noslash, // The pattern can match only the basename
    folder, // The pattern will only match a folder
    negate // The pattern is negated
  }
  return pattern
}

/** Parse many lines and build the corresponding pattern array */
function buildPatternArray(lines /*: string[] */) /*: IgnorePattern[] */ {
  return Array.from(lines).filter(isNotBlankOrComment).map(buildPattern)
}

function isNotBlankOrComment(line /*: string */) /*: boolean */ {
  return line !== '' && line[0] !== '#'
}

function match(
  path /*: string */,
  isFolder /*: boolean */,
  pattern /*: IgnorePattern */
) /*: boolean */ {
  if (pattern.basename) {
    if (pattern.match(basename(path))) {
      return true
    }
  }
  if (isFolder || !pattern.folder) {
    if (pattern.match(path)) {
      return true
    }
  }
  let parent = dirname(path)
  if (parent === '.') {
    return false
  }
  // On Windows, the various `path.*()` functions don't play well with
  // relative paths where the top-level file or directory name includes a
  // forbidden `:` character and looks like a drive letter, even without a
  // separator. Better make sure we don't end up in an infinite loop.
  if (parent === path) {
    return false
  }
  return match(parent, true, pattern)
}

/** The default rules included in the repo */
const defaultRulesPath = resolve(__dirname, './config/.cozyignore')

/**
 * Cozy-desktop can ignore some files and folders from a list of patterns in the
 * cozyignore file. This class can be used to know if a file/folder is ignored.
 */
class Ignore {
  /*::
  patterns: IgnorePattern[]
  */

  // Load patterns for detecting ignored files and folders
  constructor(lines /*: string[] */) {
    this.patterns = buildPatternArray(lines)
  }

  // Add some rules for things that should be always ignored (temporary
  // files, thumbnails db, trash, etc.)
  addDefaultRules() /*: this */ {
    const defaultPatterns = buildPatternArray(readLinesSync(defaultRulesPath))
    this.patterns = defaultPatterns.concat(this.patterns)
    return this
  }

  // Return true if the given file/folder path should be ignored
  isIgnored(
    { relativePath, isFolder } /*: {relativePath: string, isFolder: boolean} */
  ) /*: boolean */ {
    let result = false
    for (let pattern of Array.from(this.patterns)) {
      if (pattern.negate) {
        if (result) {
          result = !match(relativePath, isFolder, pattern)
        }
      } else {
        if (!result) {
          result = match(relativePath, isFolder, pattern)
        }
      }
    }
    return result
  }
}

module.exports = {
  Ignore,
  loadSync
}