#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# __coconut_hash__ = 0xa2ee9605

# Compiled with Coconut version 1.3.1 [Dead Parrot]

# Coconut Header: -------------------------------------------------------------

import sys as _coconut_sys, os.path as _coconut_os_path
_coconut_file_path = _coconut_os_path.dirname(_coconut_os_path.abspath(__file__))
_coconut_sys.path.insert(0, _coconut_file_path)
from __coconut__ import _coconut, _coconut_NamedTuple, _coconut_MatchError, _coconut_tail_call, _coconut_tco, _coconut_igetitem, _coconut_base_compose, _coconut_forward_compose, _coconut_back_compose, _coconut_forward_star_compose, _coconut_back_star_compose, _coconut_pipe, _coconut_star_pipe, _coconut_back_pipe, _coconut_back_star_pipe, _coconut_bool_and, _coconut_bool_or, _coconut_none_coalesce, _coconut_minus, _coconut_map, _coconut_partial
from __coconut__ import *
_coconut_sys.path.remove(_coconut_file_path)

# Compiled Coconut: -----------------------------------------------------------

# Standard modules
import codecs  # line 2
import fnmatch  # line 2
import json  # line 2
import logging  # line 2
import mimetypes  # line 2
import os  # line 2
import shutil  # line 2
sys = _coconut_sys  # line 2
import time  # line 2
try:  # line 3
    from typing import Any  # only required for mypy  # line 4
    from typing import Dict  # only required for mypy  # line 4
    from typing import FrozenSet  # only required for mypy  # line 4
    from typing import IO  # only required for mypy  # line 4
    from typing import Iterator  # only required for mypy  # line 4
    from typing import List  # only required for mypy  # line 4
    from typing import Set  # only required for mypy  # line 4
    from typing import Tuple  # only required for mypy  # line 4
    from typing import Union  # only required for mypy  # line 4
except:  # typing not available (e.g. Python 2)  # line 5
    pass  # typing not available (e.g. Python 2)  # line 5
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))  # line 6
try:  # line 7
    from sos import version  # line 8
    from sos.utility import *  # line 9
except:  # line 10
    import version  # line 11
    from utility import *  # line 12

# External dependencies
try:  # optional dependency  # line 15
    import configr  # optional dependency  # line 15
except:  # declare as undefined  # line 16
    configr = None  # declare as undefined  # line 16
#from enforce import runtime_validation  # https://github.com/RussBaz/enforce  TODO doesn't work for "data" types
if sys.version_info.major < 3:  # for Python 2 compatibility  # line 18
    int = long  # for Python 2 compatibility  # line 18


# Constants
APPNAME = "Subversion Offline Solution  V%s  (C) Arne Bachmann" % version.__release_version__  # type: str  # line 22
defaults = Accessor({"strict": False, "track": False, "picky": False, "compress": True, "texttype": [], "bintype": [], "ignoreDirs": [".*", "__pycache__"], "ignoreDirsWhitelist": [], "ignores": ["__coconut__.py", "*.bak", "*.py[cdo]", "*.class", ".fslckout"], "ignoresWhitelist": []})  # line 23
termWidth = getTermWidth() - 1  # uses curses or returns conservative default of 80  # line 24


# Functions
def loadConfig() -> 'Union[configr.Configr, Accessor]':  # Simplifies loading config from file system or returning the defaults  # line 28
    config = None  # type: Union[configr.Configr, Accessor]  # line 29
    if not configr:  # this only applies for the case that configr is not available, thus no settings will be persisted anyway  # line 30
        return defaults  # this only applies for the case that configr is not available, thus no settings will be persisted anyway  # line 30
    config = configr.Configr("sos", defaults=defaults)  # defaults are used if key is not configured, but won't be saved  # line 31
    f, g = config.loadSettings(clientCodeLocation=os.path.abspath(__file__), location=os.environ.get("TEST", None))  # line 32
    if f is None:  # line 33
        debug("Encountered a problem while loading the user configuration: %r" % g)  # line 33
    return config  # line 34

@_coconut_tco  # line 36
def saveConfig(c: 'configr.Configr') -> 'Tuple[_coconut.typing.Optional[str], _coconut.typing.Optional[Exception]]':  # line 36
    return _coconut_tail_call(c.saveSettings, clientCodeLocation=os.path.abspath(__file__), location=os.environ.get("TEST", None))  # line 37

def usage() -> 'None':  # line 39
    print("""/|\\ {appname} /|\\

Usage: {cmd} <command> [<argument>] [<option1>, ...]        When operating in offline mode, or command is "sos offline"
       {cmd} --sos <underlying vcs command and arguments>   When operating in offline mode, forced passthrough to traditional VCS
       {cmd} <underlying vcs command and arguments>         When operating in online mode, automatic passthrough to traditional VCS

  Available commands:
    offline [<name>]                                      Start working offline, creating a branch (named <name>), default name depending on VCS
      --plain                                               Don't compress versioned files (same as `sos config set compress off`)
      --track                                               Setup SVN-style mode: users add/remove tracking patterns per branch
      --picky                                               Setup Git-style mode: users pick files for each operation
    online                                                Finish working offline

    branch  [<name>] [--last] [--stay]                    Create a new branch from current file tree and switch to it
      --last                                                Use last revision, not current file tree, but keep file tree unchanged
      --stay                                                Don't switch to new branch, continue on current one
    switch  [<branch>][/<revision>] [--meta]              Continue work on another branch
      --meta                                                Only switch file tracking patterns for current branch, don't update any files
    update  [<branch>][/<revision>]                       Integrate work from another branch TODO add many merge and conflict resolution options
    delete  [<branch>]                                    Remove (current) branch entirely

    commit  [<message>]                                   Create a new revision from current state file tree, with an optional commit message
    changes [<branch>][/<revision>]                       List changed paths vs. last or specified revision
    diff    [<branch>][/<revision>]                       List changes vs. last or specified revision
    add     [<filename or glob pattern>]                  Add a tracking pattern to current branch (path/filename or glob pattern)
    rm      [<filename or glob pattern>]                  Remove a tracking pattern. Only useful after "offline --track" or "offline --picky"

    ls                                                    List file tree and mark changes and tracking status
    status                                                List branches and display repository status
    log                                                   List commits of current branch
    config  [set/unset/show/add/rm] [<param> [<value>]]   Configure user-global defaults.
                                                          Flags (1/0, on/off, true/false, yes/no):
                                                            strict, track, picky, compress
                                                          Lists (semicolon-separated when set; single values for add/rm):
                                                            texttype, bintype, ignores, ignoreDirs, ignoresWhitelist, ignoreDirsWhitelist
                                                          Supported texts:
                                                            defaultbranch (has a dynamic default value, depending on VCS discovered)
    help, --help                                          Show this usage information

  Arguments:
    <branch/revision>           Revision string. Branch is optional and may be a label or index
                                Revision is an optional integer and may be negative to reference from the latest commits (-1 is most recent revision)

  Common options:
    --force                     Executes potentially harmful operations
                                  for offline: ignore being already offline, start from scratch (same as 'sos online --force && sos offline')
                                  for online: ignore uncommitted branches
                                  for commit, switch, update, add: ignore uncommitted changes before executing command
    --strict                    Perform full content comparison, don't rely only on file size and timestamp
                                  for offline: persist strict mode in repository
                                  for changes, diff, commit, switch, update, delete: perform operation in strict mode
    --{cmd}                       When executing {CMD} not being offline, pass arguments to {CMD} instead (e.g. {cmd} --{cmd} config set key value.)
    --log                       Enable internals logger
    --verbose                   Enable debugging output""".format(appname=APPNAME, cmd="sos", CMD="SOS"))  # line 93

# Main data class
#@runtime_validation
class Metadata:  # line 97
    ''' This class doesn't represent the entire repository state in memory,
      but serves as a container for different repo operations,
      using only parts of its attributes at any point in time. Use with care.
  '''  # line 101

    def __init__(_, path: 'str') -> 'None':  # line 103
        ''' Create empty container object for various repository operations. '''  # line 104
        _.c = loadConfig()  # line 105
        _.root = path  # type: str  # line 106
        _.branches = {}  # type: Dict[int, BranchInfo]  # branch number zero represents the initial state at branching  # line 107
        _.commits = {}  # type: Dict[int, CommitInfo]  # consecutive numbers per branch, starting at 0  # line 108
        _.paths = {}  # type: Dict[str, PathInfo]  # utf-8 encoded relative, normalized file system paths  # line 109
        _.branch = None  # type: _coconut.typing.Optional[int]  # current branch number  # line 110
        _.commit = None  # type: _coconut.typing.Optional[int]  # current revision number  # line 111
        _.track = _.c.track  # type: bool  # track file name patterns in the repository (per branch)  # line 112
        _.picky = _.c.picky  # type: bool  # pick files to commit per operation  # line 113
        _.strict = _.c.strict  # type: bool  # be always strict (regarding file contents comparsion)  # line 114
        _.compress = _.c.compress  # type: bool  # these flags are stored in the repository  # line 115

    def isTextType(_, filename: 'str') -> 'bool':  # line 117
        return (((lambda _coconut_none_coalesce_item: "" if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(mimetypes.guess_type(filename)[0])).startswith("text/") or any([fnmatch.fnmatch(filename, pattern) for pattern in _.c.texttype])) and not any([fnmatch.fnmatch(filename, pattern) for pattern in _.c.bintype])  # line 117

    def listChanges(_, changes: 'ChangeSet') -> 'None':  # line 119
        ''' In diffmode, don't list modified files, because the diff is displayed elsewhere. '''  # line 120
        if len(changes.additions) > 0:  # line 121
            print(ajoin("ADD ", sorted(changes.additions.keys()), "\n"))  # line 121
        if len(changes.deletions) > 0:  # line 122
            print(ajoin("DEL ", sorted(changes.deletions.keys()), "\n"))  # line 122
        if len(changes.modifications) > 0:  # line 123
            print(ajoin("MOD ", sorted(changes.modifications.keys()), "\n"))  # line 123

    def loadBranches(_) -> 'None':  # line 125
        ''' Load list of branches and current branch info from metadata file. '''  # line 126
        try:  # fails if not yet created (on initial branch/commit)  # line 127
            branches = None  # type: List[Tuple]  # line 128
            with codecs.open(os.path.join(_.root, metaFolder, metaFile), "r", encoding=UTF8) as fd:  # line 129
                flags, branches = json.load(fd)  # line 130
            _.branch = flags["branch"]  # current branch integer  # line 131
            _.track = flags["track"]  # line 132
            _.picky = flags["picky"]  # line 133
            _.strict = flags["strict"]  # line 134
            _.compress = flags["compress"]  # line 135
            _.branches = {i.number: i for i in (BranchInfo(*item) for item in branches)}  # re-create type info  # line 136
        except Exception as E:  # if not found, create metadata folder  # line 137
            _.branches = {}  # line 138
            warn("Couldn't read branches metadata: %r" % E)  # line 139

    def saveBranches(_) -> 'None':  # line 141
        ''' Save list of branches and current branch info to metadata file. '''  # line 142
        with codecs.open(os.path.join(_.root, metaFolder, metaFile), "w", encoding=UTF8) as fd:  # line 143
            json.dump(({"branch": _.branch, "track": _.track, "picky": _.picky, "strict": _.strict, "compress": _.compress}, list(_.branches.values())), fd, ensure_ascii=False)  # line 144

    def getBranchByName(_, name: 'Union[str, int]') -> '_coconut.typing.Optional[int]':  # line 146
        ''' Convenience accessor for named branches. '''  # line 147
        if isinstance(name, int):  # line 148
            return name  # line 148
        try:  # attempt to parse integer string  # line 149
            return int(name)  # attempt to parse integer string  # line 149
        except ValueError:  # line 150
            pass  # line 150
        found = [number for number, branch in _.branches.items() if name == branch.name]  # line 151
        return found[0] if found else None  # line 152

    def loadBranch(_, branch: 'int') -> 'None':  # line 154
        ''' Load all commit information from a branch meta data file. '''  # line 155
        with codecs.open(os.path.join(_.root, metaFolder, "b%d" % branch, metaFile), "r", encoding=UTF8) as fd:  # line 156
            commits = json.load(fd)  # type: List[List[Any]]  # list of CommitInfo that needs to be unmarshalled into value types  # line 157
        _.commits = {i.number: i for i in (CommitInfo(*item) for item in commits)}  # re-create type info  # line 158
        _.branch = branch  # line 159

    def saveBranch(_, branch: 'int') -> 'None':  # line 161
        ''' Save all commit information to a branch meta data file. '''  # line 162
        with codecs.open(os.path.join(_.root, metaFolder, "b%d" % branch, metaFile), "w", encoding=UTF8) as fd:  # line 163
            json.dump(list(_.commits.values()), fd, ensure_ascii=False)  # line 164

    def duplicateBranch(_, branch: 'int', name: '_coconut.typing.Optional[str]') -> 'None':  # line 166
        ''' Create branch from an existing branch/revision. WARN: Caller must have loaded branches information.
        branch: target branch (should not exist yet)
        name: optional name of new branch
        _.branch: current branch
    '''  # line 171
        debug("Duplicating branch '%s' to '%d'..." % ((lambda _coconut_none_coalesce_item: "b%d" % _.branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(_.branches[_.branch].name), branch))  # line 172
        tracked = [t for t in _.branches[_.branch].tracked]  # copy  # line 173
        os.makedirs(os.path.join(_.root, metaFolder, "b%d" % branch, "r0"))  # line 174
        _.loadBranch(_.branch)  # line 175
        revision = max(_.commits)  # type: int  # line 176
        _.computeSequentialPathSet(_.branch, revision)  # full set of files in revision to _.paths  # line 177
        for path, pinfo in _.paths.items():  # line 178
            _.copyVersionedFile(_.branch, revision, branch, 0, pinfo)  # line 179
        _.commits = {0: CommitInfo(0, int(time.time() * 1000), "Branched from '%s'" % ((lambda _coconut_none_coalesce_item: "b%d" % _.branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(_.branches[_.branch].name)))}  # store initial commit  # line 180
        _.saveBranch(branch)  # save branch meta data to branch folder  # line 181
        _.saveCommit(branch, 0)  # save commit meta data to revision folder  # line 182
        _.branches[branch] = BranchInfo(branch, _.commits[0].ctime, name, _.branches[_.branch].insync, tracked)  # save branch info, before storing repo state at caller  # line 183

    def createBranch(_, branch: 'int', name: '_coconut.typing.Optional[str]'=None, initialMessage: '_coconut.typing.Optional[str]'=None):  # line 185
        ''' Create a new branch from current file tree. This clears all known commits and modifies the file system.
        branch: target branch (should not exist yet)
        name: optional name of new branch
        _.branch: current branch, must exist already
    '''  # line 190
        simpleMode = not (_.track or _.picky)  # line 191
        tracked = [t for t in _.branches[_.branch].tracked] if _.track and len(_.branches) > 0 else []  # in case of initial branch creation  # line 192
        debug((lambda _coconut_none_coalesce_item: "b%d" % branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)("Creating branch '%s'..." % name))  # line 193
        _.paths = {}  # type: Dict[str, PathInfo]  # line 194
        if simpleMode:  # branches from file system state  # line 195
            changes = _.findChanges(branch, 0, progress=simpleMode)  # type: ChangeSet  # creates revision folder and versioned files  # line 196
            _.listChanges(changes)  # line 197
            _.paths.update(changes.additions.items())  # line 198
        else:  # tracking or picky mode: branch from lastest revision  # line 199
            os.makedirs(os.path.join(_.root, metaFolder, "b%d" % branch, "r0"))  # line 200
            if _.branch is not None:  # not immediately after "offline" - copy files from current branch  # line 201
                _.loadBranch(_.branch)  # line 202
                revision = max(_.commits)  # type: int  # TODO what if last switch was to an earlier revision? no persisting of last checkout  # line 203
                _.computeSequentialPathSet(_.branch, revision)  # full set of files in revision to _.paths  # line 204
                for path, pinfo in _.paths.items():  # line 205
                    _.copyVersionedFile(_.branch, revision, branch, 0, pinfo)  # line 206
        ts = int(time.time() * 1000)  # line 207
        _.commits = {0: CommitInfo(0, ts, "Branched on %s" % strftime(ts) if initialMessage is None else initialMessage)}  # store initial commit for new branch  # line 208
        _.saveBranch(branch)  # save branch meta data (revisions) to branch folder  # line 209
        _.saveCommit(branch, 0)  # save commit meta data to revision folder  # line 210
        _.branches[branch] = BranchInfo(branch, _.commits[0].ctime, name, True if len(_.branches) == 0 else _.branches[_.branch].insync, tracked)  # save branch info, in case it is needed  # line 211

    def removeBranch(_, branch: 'int') -> 'BranchInfo':  # line 213
        ''' Entirely remove a branch and all its revisions from the file system. '''  # line 214
        shutil.rmtree(os.path.join(_.root, metaFolder, "b%d" % branch))  # line 215
        binfo = _.branches[branch]  # line 216
        del _.branches[branch]  # line 217
        _.branch = max(_.branches)  # line 218
        _.saveBranches()  # line 219
        _.commits.clear()  # line 220
        return binfo  # line 221

    def loadCommit(_, branch: 'int', revision: 'int') -> 'None':  # line 223
        ''' Load all file information from a commit meta data. '''  # line 224
        with codecs.open(os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision, metaFile), "r", encoding=UTF8) as fd:  # line 225
            _.paths = json.load(fd)  # line 226
        _.paths = {path: PathInfo(*item) for path, item in _.paths.items()}  # re-create type info  # line 227
        _.branch = branch  # line 228

    def saveCommit(_, branch: 'int', revision: 'int'):  # line 230
        ''' Save all file information to a commit meta data file. '''  # line 231
        target = os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision)  # line 232
        try:  # line 233
            os.makedirs(target)  # line 233
        except:  # line 234
            pass  # line 234
        with codecs.open(os.path.join(target, metaFile), "w", encoding=UTF8) as fd:  # line 235
            json.dump(_.paths, fd, ensure_ascii=False)  # line 236

    def findChanges(_, branch: '_coconut.typing.Optional[int]'=None, revision: '_coconut.typing.Optional[int]'=None, checkContent: 'bool'=False, inverse: 'bool'=False, considerOnly: '_coconut.typing.Optional[FrozenSet[str]]'=None, progress: 'bool'=False) -> 'ChangeSet':  # line 238
        ''' Find changes on the file system vs. in-memory paths (which should reflect the latest commit state).
        Only if both branch and revision are *not* None, write modified/added files to the specified revision folder (thus creating a new revision)
        The function returns the state of file tree *differences*, unless "inverse" is True -> then return original data
        checkContent: also computes file content hashes
        inverse: retain original state (size, mtime, hash) instead of updated one
        considerOnly: set of tracking patterns. For update operation, union of other and current branch
        progress: Show file names during processing
    '''  # line 246
        write = branch is not None and revision is not None  # line 247
        if write:  # line 248
            os.makedirs(os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision))  # line 248
        changes = ChangeSet({}, {}, {})  # type: ChangeSet  # line 249
        counter = Counter(-1)  # type: Counter  # line 250
        timer = time.time()  # line 250
        for path, dirnames, filenames in os.walk(_.root):  # line 251
            dirnames[:] = [f for f in dirnames if len([n for n in _.c.ignoreDirs if fnmatch.fnmatch(f, n)]) == 0 or len([p for p in _.c.ignoreDirsWhitelist if fnmatch.fnmatch(f, p)]) > 0]  # line 252
            filenames[:] = [f for f in filenames if len([n for n in _.c.ignores if fnmatch.fnmatch(f, n)]) == 0 or len([p for p in _.c.ignoresWhitelist if fnmatch.fnmatch(f, p)]) > 0]  # line 253
            dirnames.sort()  # line 254
            filenames.sort()  # line 254
            relpath = os.path.relpath(path, _.root).replace(os.sep, SLASH)  # line 255
            for file in (filenames if considerOnly is None else list(reduce(lambda last, pattern: last | set(fnmatch.filter(filenames, os.path.basename(pattern))), (p for p in considerOnly if os.path.dirname(p) == relpath), _coconut.set()))):  # if m.track or m.picky: only files that match any path-relevant tracking patterns  # line 256
                filename = relpath + SLASH + file  # line 257
                filepath = os.path.join(path, file)  # line 258
                stat = os.stat(filepath)  # line 259
                size, mtime, newtime = stat.st_size, int(stat.st_mtime * 1000), time.time()  # line 260
                if progress and newtime - timer > .1:  # line 261
                    outstring = "\rPreparing %s  %s" % (PROGRESS_MARKER[counter.inc() % 4], filename)  # line 262
                    sys.stdout.write(outstring + " " * max(0, termWidth - len(outstring)))  # TODO could write to new line instead of carriage return, also needs terminal width  # line 263
                    sys.stdout.flush()  # TODO could write to new line instead of carriage return, also needs terminal width  # line 263
                    timer = newtime  # TODO could write to new line instead of carriage return, also needs terminal width  # line 263
                if filename not in _.paths:  # detected file not present (or untracked) in other branch  # line 264
                    namehash = hashStr(filename)  # line 265
                    changes.additions[filename] = PathInfo(namehash, size, mtime, hashFile(filepath, _.compress, saveTo=os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision, namehash) if write else None) if size > 0 else None)  # line 266
                    continue  # line 267
                last = _.paths[filename]  # filename is known - check for modifications  # line 268
                if last.size is None:  # was removed before but is now added back - does not apply for tracking mode (which never marks files for removal in the history)  # line 269
                    changes.additions[filename] = PathInfo(last.namehash, size, mtime, hashFile(filepath, _.compress, saveTo=os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision, last.namehash) if write else None) if size > 0 else None)  # line 270
                    continue  # line 270
                elif size != last.size or mtime != last.mtime or (checkContent and hashFile(filepath, _.compress) != last.hash):  # detected a modification  # line 271
                    changes.modifications[filename] = PathInfo(last.namehash, last.size if inverse else size, last.mtime if inverse else mtime, last.hash if inverse else hashFile(filepath, _.compress, saveTo=os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision, last.namehash) if write else None) if (last.size if inverse else size) > 0 else None)  # line 272
# Detect deletions. Files no longer being tracked should not be found here
            for folderPath in (p for p, pinfo in _.paths.items() if p[:p.rindex(SLASH)] == relpath and pinfo.size is not None):  # only versioned (and tracked) files that match currently visited folder. TODO don't check for removal just added entries  # line 274
                if folderPath[len(relpath) + 1:] not in filenames:  # filename no longer in file tree TODO use basename and dirname instead of slicing?  # line 275
                    changes.deletions[folderPath] = _.paths[folderPath]  # filename no longer in file tree TODO use basename and dirname instead of slicing?  # line 275
        if progress:  # force new line  # line 276
            print("Preparation finished." + " " * (termWidth - 21), file=sys.stdout)  # force new line  # line 276
        return changes  # line 277

    def integrateChangeset(_, changes: 'ChangeSet', clear=False) -> 'None':  # line 279
        ''' In-memory update from a changeset, marking deleted files with size=None. Use clear = True to start on an empty path set. '''  # line 280
        if clear:  # line 281
            _.paths.clear()  # line 281
        else:  # line 282
            rm = [p for p, info in _.paths.items() if info.size is None]  # remove files deleted in earlier change sets (revisions)  # line 283
            for old in rm:  # remove previously removed entries completely  # line 284
                del _.paths[old]  # remove previously removed entries completely  # line 284
        for d, info in changes.deletions.items():  # mark now removed entries as deleted  # line 285
            _.paths[d] = PathInfo(info.namehash, None, info.mtime, None)  # mark now removed entries as deleted  # line 285
        _.paths.update(changes.additions)  # line 286
        _.paths.update(changes.modifications)  # line 287

    def computeSequentialPathSet(_, branch: 'int', revision: 'int') -> 'None':  # line 289
        next(_.computeSequentialPathSetIterator(branch, revision, incrementally=False))  # simply invoke the generator once  # line 290

    def computeSequentialPathSetIterator(_, branch: 'int', revision: 'int', incrementally: 'bool'=True) -> 'Optional[Iterator[ChangeSet]]':  # line 292
        ''' In-memory computation of current list of valid PathInfo entries for specified branch and until specified revision (inclusively) by traversing revision on the file system. '''  # line 293
        _.loadCommit(branch, 0)  # load initial paths  # line 294
        if incrementally:  # line 295
            yield diffPathSets({}, _.paths)  # line 295
        n = Metadata(_.root)  # type: Metadata  # next changes  # line 296
        for revision in range(1, revision + 1):  # line 297
            n.loadCommit(branch, revision)  # line 298
            changes = diffPathSets(_.paths, n.paths)  # type: ChangeSet  # line 299
            _.integrateChangeset(changes)  # line 300
            if incrementally:  # line 301
                yield changes  # line 301
        yield None  # for the default case - not incrementally  # line 302

    def parseRevisionString(_, argument: 'str') -> 'Tuple[_coconut.typing.Optional[int], _coconut.typing.Optional[int]]':  # line 304
        ''' Commit identifiers can be str or int for branch, and int for revision.
        Revision identifiers can be negative, with -1 being last commit.
    '''  # line 307
        if argument is None:  # no branch/revision specified  # line 308
            return (_.branch, -1)  # no branch/revision specified  # line 308
        argument = argument.strip()  # line 309
        if argument.startswith(SLASH):  # current branch  # line 310
            return (_.branch, int(argument[1:]))  # current branch  # line 310
        if argument.endswith(SLASH):  # line 311
            try:  # line 312
                return (_.getBranchByName(argument[:-1]), -1)  # line 312
            except ValueError:  # line 313
                Exit("Unknown branch label")  # line 313
        if SLASH in argument:  # line 314
            b, r = argument.split(SLASH)[:2]  # line 315
            try:  # line 316
                return (_.getBranchByName(b), int(r))  # line 316
            except ValueError:  # line 317
                Exit("Unknown branch label or wrong number format")  # line 317
        branch = _.getBranchByName(argument)  # type: int  # returns number if given (revision) integer  # line 318
        if branch not in _.branches:  # line 319
            branch = None  # line 319
        try:  # either branch name/number or reverse/absolute revision number  # line 320
            return (_.branch if branch is None else branch, int(argument) if branch is None else -1)  # either branch name/number or reverse/absolute revision number  # line 320
        except:  # line 321
            Exit("Unknown branch label or wrong number format")  # line 321
        return (None, None)  # should never be reached TODO raise exception instead?  # line 322

    def findRevision(_, branch: 'int', revision: 'int', namehash: 'str') -> 'Tuple[int, str]':  # line 324
        while True:  # find latest revision that contained the file physically  # line 325
            source = os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision, namehash)  # type: str  # line 326
            if os.path.exists(source) and os.path.isfile(source):  # line 327
                break  # line 327
            revision -= 1  # line 328
            if revision < 0:  # line 329
                Exit("Cannot determine versioned file '%s' from specified branch '%d'" % (namehash, branch))  # line 329
        return revision, source  # line 330

    def copyVersionedFile(_, branch: 'int', revision: 'int', tobranch: 'int', torevision: 'int', pinfo: 'PathInfo') -> 'None':  # line 332
        ''' Copy versioned file to other branch/revision. '''  # line 333
        target = os.path.join(_.root, metaFolder, "b%d" % tobranch, "r%d" % torevision, pinfo.namehash)  # type: str  # line 334
        revision, source = _.findRevision(branch, revision, pinfo.namehash)  # line 335
        shutil.copy2(source, target)  # line 336

    def readOrCopyVersionedFile(_, branch: 'int', revision: 'int', namehash: 'str', toFile: '_coconut.typing.Optional[str]'=None) -> '_coconut.typing.Optional[bytes]':  # line 338
        ''' Return file contents, or copy contents into file path provided. '''  # line 339
        source = os.path.join(_.root, metaFolder, "b%d" % branch, "r%d" % revision, namehash)  # type: str  # line 340
        try:  # line 341
            with openIt(source, "r", _.compress) as fd:  # line 342
                if toFile is None:  # read bytes into memory and return  # line 343
                    return fd.read()  # read bytes into memory and return  # line 343
                with open(toFile, "wb") as to:  # line 344
                    while True:  # line 345
                        buffer = fd.read(bufSize)  # line 346
                        to.write(buffer)  # line 347
                        if len(buffer) < bufSize:  # line 348
                            break  # line 348
                    return None  # line 349
        except Exception as E:  # line 350
            warn("Cannot read versioned file: %r (%d/%d/%s)" % (E, branch, revision, namehash))  # line 350
        return None  # line 351

    def restoreFile(_, relpath: '_coconut.typing.Optional[str]', branch: 'int', revision: 'int', pinfo: 'PathInfo') -> '_coconut.typing.Optional[bytes]':  # line 353
        ''' Recreate file for given revision, or return contents if path is None. '''  # line 354
        if relpath is None:  # just return contents  # line 355
            return _.readOrCopyVersionedFile(branch, _.findRevision(branch, revision, pinfo.namehash)[0], pinfo.namehash) if pinfo.size > 0 else b''  # just return contents  # line 355
        target = os.path.join(_.root, relpath.replace(SLASH, os.sep))  # type: str  # line 356
        if pinfo.size == 0:  # line 357
            with open(target, "wb"):  # line 358
                pass  # line 358
            try:  # update access/modification timestamps on file system  # line 359
                os.utime(target, (-1, pinfo.mtime / 1000.))  # update access/modification timestamps on file system  # line 359
            except Exception as E:  # line 360
                error("Cannot update file's timestamp after restoration '%r'" % E)  # line 360
            return None  # line 361
        revision, source = _.findRevision(branch, revision, pinfo.namehash)  # line 362
# Restore file by copying buffer-wise
        with openIt(source, "r", _.compress) as fd, open(target, "wb") as to:  # using Coconut's Enhanced Parenthetical Continuation  # line 364
            while True:  # line 365
                buffer = fd.read(bufSize)  # line 366
                to.write(buffer)  # line 367
                if len(buffer) < bufSize:  # line 368
                    break  # line 368
        try:  # update access/modification timestamps on file system  # line 369
            os.utime(target, (-1, pinfo.mtime / 1000.))  # update access/modification timestamps on file system  # line 369
        except Exception as E:  # line 370
            error("Cannot update file's timestamp after restoration '%r'" % E)  # line 370
        return None  # line 371

    def getTrackingPatterns(_, branch: '_coconut.typing.Optional[int]'=None) -> 'FrozenSet[str]':  # line 373
        ''' Returns list of tracking patterns for provided branch or current branch. '''  # line 374
        return _coconut.frozenset() if not _.track and not _.picky else frozenset(_.branches[_.branch if branch is None else branch].tracked)  # line 375


# Main client operations
def offline(argument: '_coconut.typing.Optional[str]'=None, options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 379
    ''' Initial command to start working offline. '''  # line 380
    if os.path.exists(metaFolder):  # line 381
        if '--force' not in options:  # line 382
            Exit("Repository folder is either already offline or older branches and commits were left over. Use 'sos online' or continue working offline")  # line 382
        try:  # line 383
            for entry in os.listdir(metaFolder):  # line 384
                resource = metaFolder + os.sep + entry  # line 385
                if os.path.isdir(resource):  # line 386
                    shutil.rmtree(resource)  # line 386
                else:  # line 387
                    os.unlink(resource)  # line 387
        except:  # line 388
            Exit("Cannot reliably remove previous repository contents. Please remove .sos folder manually prior to going offline")  # line 388
    m = Metadata(os.getcwd())  # type: Metadata  # line 389
    if '--plain' in options or not m.c.compress:  # plain file copies instead of compressed ones  # line 390
        m.compress = False  # plain file copies instead of compressed ones  # line 390
    if '--picky' in options or m.c.picky:  # Git-like  # line 391
        m.picky = True  # Git-like  # line 391
    elif '--track' in options or m.c.track:  # Svn-like  # line 392
        m.track = True  # Svn-like  # line 392
    if '--strict' in options or m.c.strict:  # always hash contents  # line 393
        m.strict = True  # always hash contents  # line 393
    debug("Preparing offline repository...")  # line 394
    m.createBranch(0, str(defaults["defaultbranch"]) if argument is None else argument, initialMessage="Offline repository created on %s" % strftime())  # main branch's name may be None (e.g. for fossil)  # line 395
    m.branch = 0  # line 396
    m.saveBranches()  # no change immediately after going offline, going back online won't issue a warning  # line 397
    info("Offline repository prepared. Use 'sos online' to finish offline work")  # line 398

def online(options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 400
    ''' Finish working offline. '''  # line 401
    force = '--force' in options  # type: bool  # line 402
    m = Metadata(os.getcwd())  # line 403
    m.loadBranches()  # line 404
    if any([not b.insync for b in m.branches.values()]) and not force:  # line 405
        Exit("There are still unsynchronized (dirty) branches.\nUse 'sos log' to list them.\nUse 'sos commit' and 'sos switch' to commit dirty branches to your VCS before leaving offline mode.\nUse 'sos online --force' to erase all aggregated offline revisions.")  # line 405
    strict = '--strict' in options or m.strict  # type: bool  # line 406
    if options.count("--force") < 2:  # line 407
        changes = m.findChanges(checkContent=strict, considerOnly=None if not m.track else m.getTrackingPatterns())  # type: ChangeSet  # line 408
        if modified(changes):  # line 409
            Exit("File tree is modified vs. current branch.\nUse 'sos online --force --force' to continue with removing the offline repository.")  # line 409
    try:  # line 410
        shutil.rmtree(metaFolder)  # line 410
        info("Exited offline mode. Continue working with your traditional VCS.")  # line 410
    except Exception as E:  # line 411
        Exit("Error removing offline repository: %r" % E)  # line 411

def branch(argument: '_coconut.typing.Optional[str]'=None, options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 413
    ''' Create a new branch (from file tree or last revision) and (by default) continue working on it. '''  # line 414
    last = '--last' in options  # type: bool  # use last revision for branching, not current file tree  # line 415
    stay = '--stay' in options  # type: bool  # continue on current branch after branching  # line 416
    force = '--force' in options  # type: bool  # branch even with local modifications  # line 417
    m = Metadata(os.getcwd())  # type: Metadata  # line 418
    m.loadBranches()  # line 419
    if argument and m.getBranchByName(argument) is not None:  # create a named branch  # line 420
        Exit("Branch '%s' already exists. Cannot proceed" % argument)  # create a named branch  # line 420
    branch = max(m.branches.keys()) + 1  # next branch's key - this isn't atomic but we assume single-user non-concurrent use here  # line 421
    debug("Branching to %sbranch b%02d%s%s..." % ("unnamed " if argument is None else "", branch, " '%s'" % argument if argument else "", " from last revision" if last else ""))  # line 422
    if last:  # line 423
        m.duplicateBranch(branch, argument)  # branch from branch's last revision  # line 424
    else:  # from file tree state  # line 425
        m.createBranch(branch, argument)  # branch from current file tree  # line 426
    if not stay:  # line 427
        m.branch = branch  # line 428
        m.saveBranches()  # line 429
    info("%s new %sbranch b%02d%s" % ("Continue work after branching" if stay else "Switched to", "unnamed " if argument is None else "", branch, " '%s'" % argument if argument else ""))  # line 430

def changes(argument: 'str'=None, options: '_coconut.typing.Sequence[str]'=[]) -> 'ChangeSet':  # line 432
    ''' Show changes of file tree vs. (last or specified) revision on current or specified branch. '''  # line 433
    m = Metadata(os.getcwd())  # type: Metadata  # line 434
    branch = None  # type: _coconut.typing.Optional[int]  # line 434
    revision = None  # type: _coconut.typing.Optional[int]  # line 434
    m.loadBranches()  # knows current branch  # line 435
    strict = '--strict' in options or m.strict  # type: bool  # line 436
    branch, revision = m.parseRevisionString(argument)  # line 437
    if branch not in m.branches:  # line 438
        Exit("Unknown branch")  # line 438
    m.loadBranch(branch)  # knows commits  # line 439
    revision = revision if revision >= 0 else len(m.commits) + revision  # negative indexing  # line 440
    if revision < 0 or revision > max(m.commits):  # line 441
        Exit("Unknown revision r%02d" % revision)  # line 441
    info("/|\\ Changes of file tree vs. revision '%s/r%02d'" % ((lambda _coconut_none_coalesce_item: "b%02d" % branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(m.branches[branch].name), revision))  # line 442
    m.computeSequentialPathSet(branch, revision)  # load all commits up to specified revision  # line 443
    changes = m.findChanges(checkContent=strict, considerOnly=None if not m.track else m.getTrackingPatterns() | m.getTrackingPatterns(branch))  # type: ChangeSet  # line 444
    m.listChanges(changes)  # line 445
    return changes  # line 446

def diff(argument: 'str', options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 448
    ''' Show text file differences of file tree vs. (last or specified) revision on current or specified branch. '''  # line 449
    m = Metadata(os.getcwd())  # type: Metadata  # line 450
    branch = None  # type: _coconut.typing.Optional[int]  # line 450
    revision = None  # type: _coconut.typing.Optional[int]  # line 450
    m.loadBranches()  # knows current branch  # line 451
    strict = '--strict' in options or m.strict  # type: bool  # line 452
    branch, revision = m.parseRevisionString(argument)  # line 453
    if branch not in m.branches:  # line 454
        Exit("Unknown branch")  # line 454
    m.loadBranch(branch)  # knows commits  # line 455
    revision = revision if revision >= 0 else len(m.commits) + revision  # negative indexing  # line 456
    if revision < 0 or revision > max(m.commits):  # line 457
        Exit("Unknown revision r%02d" % revision)  # line 457
    info("/|\\ Differences of file tree vs. revision '%s/r%02d'" % ((lambda _coconut_none_coalesce_item: "b%02d" % branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(m.branches[branch].name), revision))  # line 458
    m.computeSequentialPathSet(branch, revision)  # load all commits up to specified revision  # line 459
    changes = m.findChanges(checkContent=strict, inverse=True, considerOnly=None if not m.track else m.getTrackingPatterns() | m.getTrackingPatterns(branch))  # type: ChangeSet  # line 460
    onlyBinaryModifications = dataCopy(ChangeSet, changes, modifications={k: v for k, v in changes.modifications.items() if not m.isTextType(k)})  # type: ChangeSet  # line 461
    if modified(onlyBinaryModifications):  # line 462
        debug("/|\\ File changes")  # line 462
    m.listChanges(onlyBinaryModifications)  # only list modified binary files  # line 463

    if changes.modifications:  # TODO only text files, not binary  # line 465
        debug("%s/|\\ Textual modifications" % ("\n" if modified(onlyBinaryModifications) else ""))  # TODO only text files, not binary  # line 465
    for path, pinfo in changes.modifications.items():  # only consider modified files TODO also show A/D here? or replace listing above?  # line 466
        content = None  # type: _coconut.typing.Optional[bytes]  # ; othr:str[]; curr:str[]  # line 467
        if pinfo.size == 0:  # empty file contents  # line 468
            content = b""  # empty file contents  # line 468
        else:  # versioned file  # line 469
            content = m.restoreFile(None, branch, revision, pinfo)  # versioned file  # line 469
            assert content is not None  # versioned file  # line 469
        abspath = os.path.join(m.root, path.replace(SLASH, os.sep))  # current file  # line 470
        blocks = merge(file=content, intoname=abspath, diffOnly=True)  # type: List[MergeBlock]  # only determine change blocks  # line 471
        print("DIF %s%s" % (path, " <timestamp only>" if len(blocks) == 1 and blocks[0].tipe == MergeBlockType.KEEP else ""))  # line 472
        for block in blocks:  # line 473
            if block.tipe in [MergeBlockType.INSERT, MergeBlockType.REMOVE]:  # line 474
                pass  # TODO print some previous and following lines - which aren't accessible here anymore  # line 475
            if block.tipe == MergeBlockType.INSERT:  # TODO show color via curses?  # line 476
                for no, line in enumerate(block.lines):  # line 477
                    print("+++ %04d |%s|" % (no + block.line, line))  # line 478
            elif block.tipe == MergeBlockType.REMOVE:  # line 479
                for no, line in enumerate(block.lines):  # line 480
                    print("--- %04d |%s|" % (no + block.line, line))  # line 481
            elif block.tipe in [MergeBlockType.REPLACE, MergeBlockType.MODIFY]:  # TODO for MODIFY also show intra-line change ranges  # line 482
                for no, line in enumerate(block.replaces.lines):  # line 483
                    print("- | %04d |%s|" % (no + block.replaces.line, line))  # line 484
                for no, line in enumerate(block.lines):  # line 485
                    print("+ | %04d |%s|" % (no + block.line, line))  # line 486
#      elif block.tipe == MergeBlockType.KEEP: pass
#      elif block.tipe == MergeBlockType.MODIFY:  # intra-line modifications
#      elif block.tipe == MergeBlockType.MOVE:  # intra-line modifications

def commit(argument: '_coconut.typing.Optional[str]'=None, options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 491
    ''' Create new revision from file tree changes vs. last commit. '''  # line 492
    changes = None  # type: ChangeSet  # line 493
    m, branch, revision, changes, strict, force, trackingPatterns = exitOnChanges(None, options, commit=True)  # special flag creates new revision for detected changes, but abort if no changes  # line 494
    info((lambda _coconut_none_coalesce_item: "b%d" % m.branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)("Committing changes to branch '%s'..." % m.branches[m.branch].name))  # line 495
    m.integrateChangeset(changes, clear=True)  # update pathset to changeset only  # line 496
    m.saveCommit(m.branch, revision)  # revision has already been incremented  # line 497
    m.commits[revision] = CommitInfo(revision, int(time.time() * 1000), argument)  # comment can be None  # line 498
    m.saveBranch(m.branch)  # line 499
    m.loadBranches()  # TODO is it necessary to load again?  # line 500
    if m.picky:  # HINT was changed from only picky to include track as well  # line 501
        m.branches[m.branch] = dataCopy(BranchInfo, m.branches[m.branch], tracked=[], insync=False)  # remove tracked patterns  # line 502
    else:  # track or simple mode  # line 503
        m.branches[m.branch] = dataCopy(BranchInfo, m.branches[m.branch], insync=False)  # set branch dirty  # line 504
    m.saveBranches()  # line 505
    info("Created new revision r%02d%s (+%02d/-%02d/*%02d)" % (revision, ((" '%s'" % argument) if argument is not None else ""), len(changes.additions), len(changes.deletions), len(changes.modifications)))  # line 506

def status() -> 'None':  # line 508
    ''' Show branches and current repository state. '''  # line 509
    m = Metadata(os.getcwd())  # type: Metadata  # line 510
    m.loadBranches()  # knows current branch  # line 511
    current = m.branch  # type: int  # line 512
    info("/|\\ Offline repository status")  # line 513
    sl = max([len((lambda _coconut_none_coalesce_item: "" if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(b.name)) for b in m.branches.values()])  # type: int  # line 514
    for branch in sorted(m.branches.values(), key=lambda b: b.number):  # line 515
        m.loadBranch(branch.number)  # knows commit history  # line 516
        print("  %s b%02d%s @%s (%s) with %d commits%s" % ("*" if current == branch.number else " ", branch.number, ((" %%%ds" % (sl + 2)) % ("'%s'" % branch.name)) if branch.name else "", strftime(branch.ctime), "in sync" if branch.insync else "dirty", len(m.commits), ". Last comment: '%s'" % m.commits[max(m.commits)].message if m.commits[max(m.commits)].message else ""))  # line 517
    if m.track or m.picky and len(m.branches[m.branch].tracked) > 0:  # line 518
        info("\nTracked file patterns:")  # line 519
        print(ajoin("  | ", m.branches[m.branch].tracked, "\n"))  # line 520

def exitOnChanges(argument: '_coconut.typing.Optional[str]'=None, options: '_coconut.typing.Sequence[str]'=[], check: 'bool'=True, commit: 'bool'=False) -> 'Tuple[Metadata, _coconut.typing.Optional[int], int, ChangeSet, bool, bool, FrozenSet[str]]':  # line 522
    ''' Common behavior for switch, update, delete, commit.
      check: stop program on detected change
      commit: don't stop on changes, because that's what we need in the operation
      Returns (Metadata, (current or target) branch, revision, set of changes vs. last commit on current branch, strict, force flags. '''  # line 526
    m = Metadata(os.getcwd())  # type: Metadata  # line 527
    m.loadBranches()  # knows current branch  # line 528
    trackingPatterns = m.getTrackingPatterns()  # type: FrozenSet[str]  # SVN-like mode  # line 529
    force = '--force' in options  # type: bool  # line 530
    strict = '--strict' in options or m.strict  # type: bool  # line 531
    if argument is not None:  # line 532
        branch, revision = m.parseRevisionString(argument)  # for early abort  # line 533
        if branch is None:  # line 534
            Exit("Branch '%s' doesn't exist. Cannot proceed" % argument)  # line 534
    m.loadBranch(m.branch)  # knows last commits of *current* branch  # line 535

# Determine current changes
    m.computeSequentialPathSet(m.branch, max(m.commits))  # load all commits up to specified revision  # line 538
    changes = m.findChanges(m.branch if commit else None, max(m.commits) + 1 if commit else None, checkContent=strict, considerOnly=None if not m.track and not m.picky else trackingPatterns)  # type: ChangeSet  # line 539
    if modified(changes) and not force:  # and check?  # line 540
        m.listChanges(changes)  # line 541
        if check and not commit:  # line 542
            Exit("File tree contains changes. Use --force to proceed")  # line 542
    elif commit and not force:  #  and not check  # line 543
        Exit("Nothing to commit. Aborting")  #  and not check  # line 543

    if argument is not None:  # branch/revision specified  # line 545
        m.loadBranch(branch)  # knows commits of target branch  # line 546
        revision = revision if revision >= 0 else len(m.commits) + revision  # negative indexing  # line 547
        if revision < 0 or revision > max(m.commits):  # line 548
            Exit("Unknown revision r%02d" % revision)  # line 548
        return (m, branch, revision, changes, strict, force, m.getTrackingPatterns(branch))  # line 549
    return (m, m.branch, max(m.commits) + (1 if commit else 0), changes, strict, force, trackingPatterns)  # line 550

def switch(argument: 'str', options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 552
    ''' Continue work on another branch, replacing file tree changes. '''  # line 553
    changes = None  # type: ChangeSet  # line 554
    m, branch, revision, changes, strict, force, trackingPatterns = exitOnChanges(argument, options)  # line 555
    info("/|\\ Switching to branch %sb%02d/r%02d" % ("'%s' " % m.branches[branch].name if m.branches[branch].name else "", branch, revision))  # line 556

# Determine file changes from other branch to current file tree
    if '--meta' in options:  # only switch meta data  # line 559
        m.branches[m.branch] = dataCopy(BranchInfo, m.branches[m.branch], tracked=m.branches[branch].tracked)  # line 560
    else:  # full file switch  # line 561
        m.computeSequentialPathSet(branch, revision)  # load all commits up to specified revision for target branch into memory  # line 562
        changes = m.findChanges(checkContent=strict, inverse=True, considerOnly=None if not m.track else trackingPatterns | m.getTrackingPatterns(branch))  # determine difference of other branch vs. file tree (forced or in sync with current branch; "addition" means exists now and should be removed)  # line 563
        if not modified(changes):  # line 564
            info("No changes to current file tree")  # line 565
        else:  # integration required  # line 566
            for path, pinfo in changes.deletions.items():  # line 567
                m.restoreFile(path, branch, revision, pinfo)  # is deleted in current file tree: restore from branch to reach target  # line 568
                debug("ADD " + path)  # line 569
            for path, pinfo in changes.additions.items():  # line 570
                os.unlink(os.path.join(m.root, path.replace(SLASH, os.sep)))  # is added in current file tree: remove from branch to reach target  # line 571
                debug("DEL " + path)  # line 572
            for path, pinfo in changes.modifications.items():  # line 573
                m.restoreFile(path, branch, revision, pinfo)  # is modified in current file tree: restore from branch to reach target  # line 574
                debug("MOD " + path)  # line 575
    m.branch = branch  # line 576
    m.saveBranches()  # store switched path info  # line 577
    info("Switched to branch %sb%02d/r%02d" % ("'%s' " % (m.branches[branch].name if m.branches[branch].name else ""), branch, revision))  # line 578

def update(argument: 'str', options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 580
    ''' Load and integrate a specified other branch/revision into current life file tree.
      In tracking mode, this also updates the set of tracked patterns.
      User options for merge operation: --add (don't remove), --rm (don't insert), --add-lines/--rm-lines (inside each file)
      User options for conflict resolution: --theirs/--mine/--ask
  '''  # line 585
    mrg = firstOfMap({"--add": MergeOperation.INSERT, "--rm": MergeOperation.REMOVE}, options, MergeOperation.BOTH)  # type: int  # default operation is replicate remove state for non-conflicting cases (insertions/deletions)  # line 586
    res = firstOfMap({'--theirs': ConflictResolution.THEIRS, '--mine': ConflictResolution.MINE}, options, ConflictResolution.NEXT)  # type: int  # NEXT means deeper level  # line 587
    mrgline = firstOfMap({'--add-lines': MergeOperation.INSERT, '--rm-lines': MergeOperation.REMOVE}, options, mrg)  # type: int  # default operation for modified files is same as for files  # line 588
    resline = firstOfMap({'--theirs-lines': ConflictResolution.THEIRS, '--mine-lines': ConflictResolution.MINE}, options, ConflictResolution.ASK)  # type: int  # line 589
    m = Metadata(os.getcwd())  # type: Metadata  # TODO same is called inside stop on changes - could return both current and designated branch instead  # line 590
    m.loadBranches()  # line 591
    changes = None  # type: ChangeSet  # line 591
    currentBranch = m.branch  # type: _coconut.typing.Optional[int]  # line 592
    m, branch, revision, changes, strict, force, trackingPatterns = exitOnChanges(argument, options, check=False)  # don't check for current changes, only parse arguments  # line 593
    debug("Integrating changes from '%s/r%02d' into file tree..." % ((lambda _coconut_none_coalesce_item: "b%02d" % branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(m.branches[branch].name), revision))  # line 594

# Determine file changes from other branch over current file tree
    m.computeSequentialPathSet(branch, revision)  # load all commits up to specified revision for branch to integrate  # line 597
    trackingUnion = trackingPatterns | m.getTrackingPatterns(branch)  # type: FrozenSet[str]  # line 598
    changes = m.findChanges(checkContent=strict, inverse=True, considerOnly=None if not m.track else trackingUnion)  # determine difference of other branch vs. file tree. "addition" means exists now but not in other, and should be removed unless in tracking mode  # line 599
    if not (mrg & MergeOperation.INSERT and changes.additions or (mrg & MergeOperation.REMOVE and changes.deletions) or changes.modifications):  # line 600
        if trackingUnion == trackingPatterns:  # nothing added  # line 601
            info("No file changes detected, but tracking patterns were merged (run 'sos switch /-1 --meta' to undo)")  # TODO write test to see if this works  # line 602
        else:  # line 603
            info("Nothing to update")  # but write back updated branch info below  # line 604
    else:  # integration required  # line 605
        for path, pinfo in changes.deletions.items():  # deletions mark files not present in current file tree -> needs additon  # line 606
            if mrg & MergeOperation.INSERT:  # deleted in current file tree: restore from branch to reach target  # line 607
                m.restoreFile(path, branch, revision, pinfo)  # deleted in current file tree: restore from branch to reach target  # line 607
            print("ADD " + path if mrg & MergeOperation.INSERT else "(A) " + path)  # line 608
        for path, pinfo in changes.additions.items():  # line 609
            if m.track or m.picky:  # because untracked files of other branch cannot be detected (which is good)  # line 610
                Exit("This should never happen")  # because untracked files of other branch cannot be detected (which is good)  # line 610
            if mrg & MergeOperation.REMOVE:  # line 611
                os.unlink(m.root + os.sep + path.replace(SLASH, os.sep))  # line 611
            print("DEL " if mrg & MergeOperation.REMOVE else "(D) " + path)  # not contained in other branch, but maybe kept  # line 612
        for path, pinfo in changes.modifications.items():  # line 613
            into = os.path.join(m.root, path.replace(SLASH, os.sep))  # type: str  # line 614
            binary = not m.isTextType(path)  # line 615
            if res & ConflictResolution.ASK or (binary and res == ConflictResolution.NEXT):  # TODO this may ask user even if no interaction was asked for (check line level for interactivity fist)  # line 616
                print(("MOD " if not binary else "BIN ") + path)  # line 617
                reso = {"i": ConflictResolution.MINE, "t": ConflictResolution.THEIRS, "m": ConflictResolution.NEXT}.get(user_input(" Resolve: *M[I]ne, [T]heirs, [M]erge: (I)").strip().lower(), ConflictResolution.MINE)  # line 618
                debug("User selected %d" % reso)  # line 619
            else:  # line 620
                reso = res  # line 620
            if reso & ConflictResolution.THEIRS:  # line 621
                m.readOrCopyVersionedFile(branch, revision, pinfo.namehash, toFile=into)  # blockwise copy of contents  # line 622
                print("THR " + path)  # line 623
            elif reso & ConflictResolution.MINE:  # line 624
                print("MNE " + path)  # nothing to do! same as skip  # line 625
            else:  # NEXT: line-based merge  # line 626
                file = m.readOrCopyVersionedFile(branch, revision, pinfo.namehash) if pinfo.size > 0 else b''  # type: _coconut.typing.Optional[bytes]  # parse lines TODO decode etc.  # line 627
                if file is not None:  # if None, error message was already logged  # line 628
                    contents = merge(file=file, intoname=into, mergeOperation=mrgline, conflictResolution=resline)  # type: bytes  # line 629
                    with open(path, "wb") as fd:  # TODO write to temp file first  # line 630
                        fd.write(contents)  # TODO write to temp file first  # line 630
    info("Integrated changes from '%s/r%02d' into file tree" % ((lambda _coconut_none_coalesce_item: "b%02d" % branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(m.branches[branch].name), revision))  # line 631
    m.branches[currentBranch] = dataCopy(BranchInfo, m.branches[currentBranch], insync=False, tracked=list(trackingUnion))  # TODO really? it's a change that cannot be undone easily by the user  # line 632
    m.branch = currentBranch  # need to restore setting before saving TODO operate on different objects instead  # line 633
    m.saveBranches()  # line 634

def delete(argument: 'str', options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 636
    ''' Remove a branch entirely. '''  # line 637
    m, branch, revision, changes, strict, force, trackingPatterns = exitOnChanges(None, options)  # line 638
    if len(m.branches) == 1:  # line 639
        Exit("Cannot remove the only remaining branch. Use 'sos online' to leave offline mode")  # line 639
    branch, revision = m.parseRevisionString(argument)  # not from exitOnChanges, because we have to set argument to None there  # line 640
    if branch is None or branch not in m.branches:  # line 641
        Exit("Unknown branch")  # line 641
    debug("Removing branch %d%s..." % (branch, " '%s'" % m.branches[branch].name if m.branches[branch].name else ""))  # line 642
    binfo = m.removeBranch(branch)  # need to keep a reference to removed entry for output below  # line 643
    info("Branch b%02d%s removed" % (branch, " '%s'" % binfo.name if binfo.name else ""))  # line 644

def add(folder: 'str', argument: 'str', options: '_coconut.typing.Sequence[str]'=[]) -> 'None':  # line 646
    ''' Add a tracked files pattern to current branch's tracked files. '''  # line 647
    force = '--force' in options  # type: bool  # line 648
    m = Metadata(os.getcwd())  # type: Metadata  # line 649
    m.loadBranches()  # line 650
    if not m.track and not m.picky:  # line 651
        Exit("Repository is in simple mode. Needs 'offline --track' or 'offline --picky' instead")  # line 651
    relpath = os.path.relpath(os.path.dirname(os.path.abspath(os.path.join(folder, argument))), m.root)  # for tracking list  # line 652
    pattern = os.path.join(relpath, os.path.basename(argument)).replace(os.sep, SLASH)  # line 653
    if pattern in m.branches[m.branch].tracked:  # line 654
        Exit("Pattern '%s' already tracked")  # line 655
    if not force and len(fnmatch.filter(os.listdir(os.path.abspath(relpath)), os.path.basename(pattern.replace(SLASH, os.sep)))) == 0:  # doesn't match any current file  # line 656
        Exit("Pattern doesn't match any file in specified folder. Use --force to add it anyway")  # line 657
    m.branches[m.branch].tracked.append(pattern)  # TODO set insync flag to False? same for rm  # line 658
    m.saveBranches()  # line 659
    info("Added tracking pattern '%s' for folder '%s'" % (os.path.basename(pattern.replace(SLASH, os.sep)), os.path.abspath(relpath)))  # line 660

def rm(folder: 'str', argument: 'str') -> 'None':  # line 662
    ''' Remove a tracked files pattern from current branch's tracked files. '''  # line 663
    m = Metadata(os.getcwd())  # type: Metadata  # line 664
    m.loadBranches()  # line 665
    if not m.track and not m.picky:  # line 666
        Exit("Repository is in simple mode. Needs 'offline --track' or 'offline --picky' instead")  # line 666
    relpath = os.path.relpath(os.path.dirname(os.path.abspath(os.path.join(folder, argument))), m.root)  # type: str  # line 667
    pattern = os.path.join(relpath, os.path.basename(argument)).replace(os.sep, SLASH)  # type: str  # line 668
    if pattern not in m.branches[m.branch].tracked:  # line 669
        suggestion = _coconut.set()  # type: Set[str]  # line 670
        for pat in m.branches[m.branch].tracked:  # line 671
            if fnmatch.fnmatch(pattern, pat):  # line 672
                suggestion.add(pat)  # line 672
        if suggestion:  # line 673
            print("Do you mean any of the following tracked globs? '%s'" % (", ".join(sorted(suggestion))))  # line 673
        Exit("Tracked pattern '%s' not found" % pattern)  # line 674
    m.branches[m.branch].tracked.remove(pattern)  # line 675
    m.saveBranches()  # line 676
    info("Removed tracking pattern '%s' for folder '%s'" % (os.path.basename(pattern.replace(SLASH, os.sep)), os.path.abspath(relpath)))  # line 677

def ls(argument: '_coconut.typing.Optional[str]'=None) -> 'None':  # line 679
    ''' List specified directory, augmenting with repository metadata. '''  # line 680
    cwd = os.getcwd() if argument is None else argument  # type: str  # line 681
    m = Metadata(cwd)  # type: Metadata  # line 682
    m.loadBranches()  # line 683
    relpath = os.path.relpath(cwd, m.root).replace(os.sep, SLASH)  # type: str  # line 684
    files = list(sorted((entry for entry in os.listdir(cwd) if os.path.isfile(entry))))  # type: List[str]  # line 685
    trackingPatterns = m.getTrackingPatterns() if m.track or m.picky else _coconut.frozenset()  # type: FrozenSet[str]  # for current branch  # line 686
    for file in files:  # line 687
        ignore = None  # type: _coconut.typing.Optional[str]  # line 688
        for ig in m.c.ignores:  # line 689
            if fnmatch.fnmatch(file, ig):  # remember first match TODO document this  # line 690
                ignore = ig  # remember first match TODO document this  # line 690
                break  # remember first match TODO document this  # line 690
        if ig:  # line 691
            for wl in m.c.ignoresWhitelist:  # line 692
                if fnmatch.fnmatch(file, wl):  # found a white list entry for ignored file, undo ignoring it  # line 693
                    ignore = None  # found a white list entry for ignored file, undo ignoring it  # line 693
                    break  # found a white list entry for ignored file, undo ignoring it  # line 693
        if ignore is None:  # line 694
            matches = []  # type: List[str]  # line 695
            for pattern in (p for p in trackingPatterns if os.path.dirname(p) == relpath):  # only patterns matching current folder  # line 696
                if fnmatch.fnmatch(file, os.path.basename(pattern)):  # TODO or only file basename?  # line 697
                    matches.append(pattern)  # TODO or only file basename?  # line 697
        print("%s %s%s" % ("IGN" if ignore is not None else ("TRK" if len(matches) > 0 else "   "), file, ' by "%s"' % ignore if ignore is not None else (" by " + ";".join(['"%s"' % match for match in matches]) if len(matches) > 0 else "")))  # line 698

def log() -> 'None':  # line 700
    ''' List previous commits on current branch. '''  # TODO --verbose for changesets  # line 701
    m = Metadata(os.getcwd())  # type: Metadata  # line 702
    m.loadBranches()  # knows current branch  # line 703
    m.loadBranch(m.branch)  # knows commit history  # line 704
    info((lambda _coconut_none_coalesce_item: "r%02d" % m.branch if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)("/|\\ Offline commit history of branch '%s'" % m.branches[m.branch].name))  # TODO also retain "from branch/revision" on branching?  # line 705
    nl = len("%d" % max(m.commits))  # determine space needed for revision  # line 706
    changeset = m.computeSequentialPathSetIterator(m.branch, max(m.commits))  # line 707
    for no in range(max(m.commits) + 1):  # line 708
        commit = m.commits[no]  # type: CommitInfo  # line 709
        changes = next(changeset)  # type: ChangeSet  # side-effect: updates m.paths  # line 710
        print("  %s r%s @%s (+%02d/-%02d/*%02d): %s" % ("*" if commit.number == max(m.commits) else " ", ("%%%ds" % nl) % commit.number, strftime(commit.ctime), len(changes.additions), len(changes.deletions), len(changes.modifications), (lambda _coconut_none_coalesce_item: "" if _coconut_none_coalesce_item is None else _coconut_none_coalesce_item)(commit.message)))  # line 711
# TODO list number of files and if binary/text

def config(argument: 'str', options: 'List[str]'=[]) -> 'None':  # line 714
    if argument not in ["set", "unset", "show", "list", "add", "rm"]:  # line 715
        Exit("Unknown config command")  # line 715
    if not configr:  # line 716
        Exit("Cannot execute config command. 'configr' module not installed")  # line 716
    c = loadConfig()  # type: Union[configr.Configr, Accessor]  # line 717
    if argument == "set":  # line 718
        if len(options) < 2:  # line 719
            Exit("No key nor value specified")  # line 719
        if options[0] not in (["defaultbranch"] + CONFIGURABLE_FLAGS + CONFIGURABLE_LISTS):  # line 720
            Exit("Unsupported key %r" % options[0])  # line 720
        if options[0] in CONFIGURABLE_FLAGS and options[1].lower() not in TRUTH_VALUES + FALSE_VALUES:  # line 721
            Exit("Cannot set flag to '%s'. Try on/off instead" % options[1].lower())  # line 721
        c[options[0]] = options[1].lower() in TRUTH_VALUES if options[0] in CONFIGURABLE_FLAGS else (options[1].strip() if options[0] not in CONFIGURABLE_LISTS else safeSplit(options[1], ";"))  # TODO sanitize texts?  # line 722
    elif argument == "unset":  # line 723
        if len(options) < 1:  # line 724
            Exit("No key specified")  # line 724
        if options[0] not in c.keys():  # line 725
            Exit("Unknown key")  # line 725
        del c[options[0]]  # line 726
    elif argument == "add":  # line 727
        if len(options) < 2:  # line 728
            Exit("No key nor value specified")  # line 728
        if options[0] not in CONFIGURABLE_LISTS:  # line 729
            Exit("Unsupported key for add %r" % options[0])  # line 729
        if options[0] not in c.keys():  # add list  # line 730
            c[options[0]] = [options[1]]  # add list  # line 730
        elif options[1] in c[options[0]]:  # line 731
            Exit("Value already contained")  # line 731
        c[options[0]].append(options[1])  # line 732
    elif argument == "rm":  # line 733
        if len(options) < 2:  # line 734
            Exit("No key nor value specified")  # line 734
        if options[0] not in c.keys():  # line 735
            Exit("Unknown key specified: %r" % options[0])  # line 735
        if options[1] not in c[options[0]]:  # line 736
            Exit("Unknown value: %r" % options[1])  # line 736
        c[options[0]].remove(options[1])  # line 737
    else:  # Show  # line 738
        for k, v in sorted(c.items()):  # line 739
            print("%s => %r" % (k, v))  # line 739
        return  # line 740
    f, g = saveConfig(c)  # line 741
    if f is None:  # line 742
        error("Error saving user configuration: %r" % g)  # line 742

def parse(root: 'str', inFolder: 'str'):  # line 744
    ''' Main operation. '''  # line 745
    debug("Parsing command-line arguments...")  # line 746
    command = sys.argv[1].strip() if len(sys.argv) > 1 else ""  # line 747
    argument = sys.argv[2].strip() if len(sys.argv) > 2 else None  # line 748
    options = sys.argv[3:] if command == "config" else [c for c in sys.argv[2:] if c.startswith("--")]  # consider second parameter for no-arg command, otherwise from third on  # line 749
    debug("Processing command %r with argument '%s' and options %r." % ("" if command is None else command, "" if argument is None else argument, options))  # line 750
    if command[:1] == "a":  # line 751
        add(inFolder, argument, options)  # line 751
    elif command[:1] == "b":  # line 752
        branch(argument, options)  # line 752
    elif command[:2] == "ch":  # line 753
        changes(argument, options)  # line 753
    elif command[:3] == "com" or command[:2] == "ci":  # line 754
        commit(argument, options)  # line 754
    elif command[:3] == 'con':  # line 755
        config(argument, options)  # line 755
    elif command[:2] == "de":  # line 756
        delete(argument, options)  # line 756
    elif command[:2] == "di":  # line 757
        diff(argument, options)  # line 757
    elif command[:1] == "h":  # line 758
        usage()  # line 758
    elif command[:2] == "lo":  # line 759
        log()  # line 759
    elif command[:2] in ["li", "ls"]:  # line 760
        ls(argument)  # line 760
    elif command[:2] == "of":  # line 761
        offline(argument, options)  # line 761
    elif command[:2] == "on":  # line 762
        online(options)  # line 762
    elif command[:1] == "r":  # line 763
        rm(inFolder, argument)  # line 763
    elif command[:2] == "st":  # line 764
        status()  # line 764
    elif command[:2] == "sw":  # line 765
        switch(argument, options)  # line 765
    elif command[:2] == "u":  # line 766
        update(argument, options)  # line 766
    else:  # line 767
        Exit("Unknown command '%s'" % command)  # line 767
    sys.exit(0)  # line 768

def main() -> 'None':  # line 770
    global debug, info, warn, error  # line 771
    logging.basicConfig(level=level, stream=sys.stderr, format=("%(asctime)-23s %(levelname)-8s %(name)s:%(lineno)d | %(message)s" if '--log' in sys.argv else "%(message)s"))  # line 772
    _log = Logger(logging.getLogger(__name__))  # line 773
    debug, info, warn, error = _log.debug, _log.info, _log.warn, _log.error  # line 773
    for option in (o for o in ['--log', '--verbose', '-v', '--sos'] if o in sys.argv):  # clean up program arguments  # line 774
        sys.argv.remove(option)  # clean up program arguments  # line 774
    if '--help' in sys.argv or len(sys.argv) < 2:  # line 775
        usage()  # line 775
        Exit()  # line 775
    command = sys.argv[1] if len(sys.argv) > 1 else None  # type: _coconut.typing.Optional[str]  # line 776
    root, vcs, cmd = findSosVcsBase()  # root is None if no .sos folder exists up the folder tree (still working online); vcs is checkout/repo root folder; cmd is the VCS base command  # line 777
    debug("Found root folders for SOS|VCS: %s|%s" % ("" if root is None else root, "" if vcs is None else vcs))  # line 778
    defaults["defaultbranch"] = vcsBranches.get(cmd, "trunk")  # sets dynamic default with SVN fallback  # line 779
    if force_sos or root is not None or ("" if command is None else command)[:2] == "of" or ("" if command is None else command)[:1] == "h":  # in offline mode or just going offline TODO what about git config?  # line 780
        cwd = os.getcwd()  # line 781
        os.chdir(cwd if command[:2] == "of" else cwd if root is None else root)  # line 782
        parse(root, cwd)  # line 783
    elif cmd is not None:  # online mode - delegate to VCS  # line 784
        info("SOS: Running '%s %s'" % (cmd, " ".join(sys.argv[1:])))  # line 785
        import subprocess  # only requuired in this section  # line 786
        process = subprocess.Popen([cmd] + sys.argv[1:], shell=False, stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr)  # line 787
        inp = ""  # type: str  # line 788
        while True:  # line 789
            so, se = process.communicate(input=inp)  # line 790
            if process.returncode is not None:  # line 791
                break  # line 791
            inp = sys.stdin.read()  # line 792
        if sys.argv[1][:2] == "co" and process.returncode == 0:  # successful commit - assume now in sync again (but leave meta data folder with potential other feature branches behind until "online")  # line 793
            if root is None:  # line 794
                Exit("Cannot determine VCS root folder: Unable to mark repository as synchronized and will show a warning when leaving offline mode")  # line 794
            m = Metadata(root)  # line 795
            m.loadBranches()  # read repo  # line 796
            m.branches[m.branch] = dataCopy(BranchInfo, m.branches[m.branch], insync=True)  # mark as committed  # line 797
            m.saveBranches()  # line 798
    else:  # line 799
        Exit("No offline repository present, and unable to detect VCS file tree")  # line 799


# Main part
level = logging.DEBUG if os.environ.get("DEBUG", "False").lower() == "true" or '--verbose' in sys.argv or '-v' in sys.argv else logging.INFO  # line 803
force_sos = '--sos' in sys.argv  # line 804
_log = Logger(logging.getLogger(__name__))  # line 805
debug, info, warn, error = _log.debug, _log.info, _log.warn, _log.error  # line 805
if __name__ == '__main__':  # line 806
    main()  # line 806
