summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorSoniEx2 <endermoneymod@gmail.com>2020-07-19 20:38:05 -0300
committerSoniEx2 <endermoneymod@gmail.com>2020-07-19 20:38:05 -0300
commitaf3fb6c6eb233af00229a908bab92afbf38bad0b (patch)
tree6aaaa1726c585d25fa84d1a09cb4b1950278097f
parentf216c88680a880db75ab24d9ce8fa35f327ac697 (diff)
Move a lot of things around (broken)
-rw-r--r--ganarchy/__init__.py527
-rw-r--r--ganarchy/__main__.py3
-rw-r--r--ganarchy/cli/__init__.py6
-rw-r--r--ganarchy/cli/db.py71
-rw-r--r--ganarchy/cli/run_targets.py111
-rw-r--r--ganarchy/core.py165
-rw-r--r--ganarchy/data.py7
-rw-r--r--ganarchy/db.py336
-rw-r--r--ganarchy/dirs.py53
-rw-r--r--ganarchy/git.py131
-rw-r--r--ganarchy/templating/__init__.py21
-rw-r--r--ganarchy/templating/environment.py29
-rw-r--r--ganarchy/templating/templates.py124
-rw-r--r--ganarchy/templating/toml.py33
14 files changed, 1107 insertions, 510 deletions
diff --git a/ganarchy/__init__.py b/ganarchy/__init__.py
index c7bc025..206bac9 100644
--- a/ganarchy/__init__.py
+++ b/ganarchy/__init__.py
@@ -1,5 +1,5 @@
 # GAnarchy - decentralized project hub
-# Copyright (C) 2019  Soni L.
+# Copyright (C) 2019, 2020  Soni L.
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -14,509 +14,24 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
-import abc
-import builtins
-import hashlib
-import hmac
-import os
-import re
-import sqlite3
-import subprocess
-
-import abdl
-import click
-import jinja2
-import qtoml
-import requests
-
-from collections import defaultdict
-from urllib.parse import urlparse
-
-import ganarchy as m_ganarchy
-
-MIGRATIONS = {
-        "toml-config": (
-                (
-                    '''UPDATE "repo_history" SET "project" = (SELECT "git_commit" FROM "config") WHERE "project" IS NULL''',
-                    '''ALTER TABLE "repos" RENAME TO "repos_old"''',),
-                (
-                    '''UPDATE "repo_history" SET "project" = NULL WHERE "project" = (SELECT "git_commit" FROM "config")''',
-                    '''ALTER TABLE "repos_old" RENAME TO "repos"''',),
-                "switches to toml config format. the old 'repos' table is preserved as 'repos_old'"
-            ),
-        "better-project-management": (
-                (
-                    '''ALTER TABLE "repos" ADD COLUMN "branch" TEXT''',
-                    '''ALTER TABLE "repos" ADD COLUMN "project" TEXT''',
-                    '''CREATE UNIQUE INDEX "repos_url_branch_project" ON "repos" ("url", "branch", "project")''',
-                    '''CREATE INDEX "repos_project" ON "repos" ("project")''',
-                    '''ALTER TABLE "repo_history" ADD COLUMN "branch" TEXT''',
-                    '''ALTER TABLE "repo_history" ADD COLUMN "project" TEXT''',
-                    '''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''',),
-                (
-                    '''DELETE FROM "repos" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',
-                    '''DELETE FROM "repo_history" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',),
-                "supports multiple projects, and allows choosing non-default branches"
-            ),
-        "test": (
-                ('''-- apply''',),
-                ('''-- revert''',),
-                "does nothing"
-            )
-        }
-
-data_home = os.environ.get('XDG_DATA_HOME', '')
-if not data_home:
-    data_home = os.environ['HOME'] + '/.local/share'
-data_home = data_home + "/ganarchy"
-
-cache_home = os.environ.get('XDG_CACHE_HOME', '')
-if not cache_home:
-    cache_home = os.environ['HOME'] + '/.cache'
-cache_home = cache_home + "/ganarchy"
-
-config_home = os.environ.get('XDG_CONFIG_HOME', '')
-if not config_home:
-    config_home = os.environ['HOME'] + '/.config'
-config_home = config_home + "/ganarchy"
-
-config_dirs = os.environ.get('XDG_CONFIG_DIRS', '')
-if not config_dirs:
-    config_dirs = '/etc/xdg'
-# TODO check if this is correct
-config_dirs = [config_dir + "/ganarchy" for config_dir in config_dirs.split(':')]
-
-def get_template_loader():
-    from jinja2 import DictLoader, FileSystemLoader, ChoiceLoader
-    return ChoiceLoader([
-        FileSystemLoader([config_home + "/templates"] + [config_dir + "/templates" for config_dir in config_dirs]),
-        DictLoader({
-            ## index.html
-            'index.html': """<!DOCTYPE html>
-<html lang="en">
-    <head>
-        <meta charset="utf-8" />
-        <!--
-        GAnarchy - project homepage generator
-        Copyright (C) 2019  Soni L.
-
-        This program is free software: you can redistribute it and/or modify
-        it under the terms of the GNU General Public License as published by
-        the Free Software Foundation, either version 3 of the License, or
-        (at your option) any later version.
-
-        This program is distributed in the hope that it will be useful,
-        but WITHOUT ANY WARRANTY; without even the implied warranty of
-        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-        GNU General Public License for more details.
-
-        You should have received a copy of the GNU General Public License
-        along with this program.  If not, see <https://www.gnu.org/licenses/>.
-        -->
-        <title>{{ ganarchy.title|e }}</title>
-        <meta name="description" content="{{ ganarchy.title|e }}" />
-        <!--if your browser doesn't like the following, use a different browser.-->
-        <script type="application/javascript" src="/index.js"></script>
-    </head>
-    <body>
-        <h1>{{ ganarchy.title|e }}</h1>
-        <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p>
-        <ul>
-        {% for project in ganarchy.projects -%}
-            <li><a href="/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li>
-        {% endfor -%}
-        </ul>
-        <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
-        <p>
-            <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
-        </p>
-    </body>
-</html>
-""",
-            ## index.toml
-            'index.toml': """# Generated by GAnarchy
-
-{%- for project, repos in config.projects.items() %}
-[projects.{{project}}]
-{%- for repo_url, branches in repos.items() %}{% for branch, options in branches.items() %}{% if options.active %}
-"{{repo_url|tomle}}".{% if branch %}"{{branch|tomle}}"{% else %}HEAD{% endif %} = { active=true }
-{%- endif %}{% endfor %}
-{%- endfor %}
-{% endfor -%}
-""",
-            ## project.html FIXME
-            'project.html': """<!DOCTYPE html>
-<html lang="en">
-    <head>
-        <meta charset="utf-8" />
-        <!--
-        GAnarchy - project homepage generator
-        Copyright (C) 2019  Soni L.
-
-        This program is free software: you can redistribute it and/or modify
-        it under the terms of the GNU General Public License as published by
-        the Free Software Foundation, either version 3 of the License, or
-        (at your option) any later version.
-
-        This program is distributed in the hope that it will be useful,
-        but WITHOUT ANY WARRANTY; without even the implied warranty of
-        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-        GNU General Public License for more details.
-
-        You should have received a copy of the GNU General Public License
-        along with this program.  If not, see <https://www.gnu.org/licenses/>.
-        -->
-        <title>{{ project_title|e }}</title>
-        {% if project_desc %}<meta name="description" content="{{ project_desc|e }}" />{% endif %}
-        <style type="text/css">.branchname { color: #808080; font-style: italic; }</style>
-    </head>
-    <body>
-        <h1>{{ project_title|e }}</h1>
-        <p>Tracking <span id="project_commit"><a href="web+ganarchy:{{ project_commit }}">{{ project_commit }}</a></span></p>
-        <div id="project_body"><p>{{ project_body|e|replace("\n\n", "</p><p>") }}</p></div>
-        <ul>
-        {% for url, msg, img, branch in repos -%}
-            <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li>
-        {% endfor -%}
-        </ul>
-        <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
-        <p>
-            <a href="/">Main page</a>.
-            <a href="{{ base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
-        </p>
-    </body>
-</html>
-""",
-            ## history.svg FIXME
-            'history.svg': """""",
-        })
-    ])
-
-tomletrans = str.maketrans({
-    0: '\\u0000', 1: '\\u0001', 2: '\\u0002', 3: '\\u0003', 4: '\\u0004',
-    5: '\\u0005', 6: '\\u0006', 7: '\\u0007', 8: '\\b', 9: '\\t', 10: '\\n',
-    11: '\\u000B', 12: '\\f', 13: '\\r', 14: '\\u000E', 15: '\\u000F',
-    16: '\\u0010', 17: '\\u0011', 18: '\\u0012', 19: '\\u0013', 20: '\\u0014',
-    21: '\\u0015', 22: '\\u0016', 23: '\\u0017', 24: '\\u0018', 25: '\\u0019',
-    26: '\\u001A', 27: '\\u001B', 28: '\\u001C', 29: '\\u001D', 30: '\\u001E',
-    31: '\\u001F', '"': '\\"', '\\': '\\\\'
-    })
-def tomlescape(value):
-    return value.translate(tomletrans)
-
-def get_env():
-    env = jinja2.Environment(loader=get_template_loader(), autoescape=False)
-    env.filters['tomlescape'] = tomlescape
-    env.filters['tomle'] = env.filters['tomlescape']
-    return env
-
-# FIXME
-from ganarchy.cli import main as ganarchy
-
-@ganarchy.command()
-def initdb():
-    """Initializes the ganarchy database."""
-    os.makedirs(data_home, exist_ok=True)
-    conn = sqlite3.connect(data_home + "/ganarchy.db")
-    c = conn.cursor()
-    c.execute('''CREATE TABLE "repo_history" ("entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT, "url" TEXT, "count" INTEGER, "head_commit" TEXT, "branch" TEXT, "project" TEXT)''')
-    c.execute('''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''')
-    conn.commit()
-    conn.close()
-
-def migrations():
-    @ganarchy.group()
-    def migrations():
-        """Modifies the DB to work with a newer/older version.
-
-        WARNING: THIS COMMAND CAN BE EXTREMELY DESTRUCTIVE!"""
-
-    @migrations.command()
-    @click.argument('migration')
-    def apply(migration):
-        """Applies the migration with the given name."""
-        conn = sqlite3.connect(data_home + "/ganarchy.db")
-        c = conn.cursor()
-        click.echo(MIGRATIONS[migration][0])
-        for migration in MIGRATIONS[migration][0]:
-            c.execute(migration)
-        conn.commit()
-        conn.close()
-
-    @click.argument('migration')
-    @migrations.command()
-    def revert(migration):
-        """Reverts the migration with the given name."""
-        conn = sqlite3.connect(data_home + "/ganarchy.db")
-        c = conn.cursor()
-        click.echo(MIGRATIONS[migration][1])
-        for migration in MIGRATIONS[migration][1]:
-            c.execute(migration)
-        conn.commit()
-        conn.close()
-
-    @click.argument('migration', required=False)
-    @migrations.command()
-    def info(migration):
-        """Shows information about the migration with the given name."""
-        if not migration:
-            # TODO could be improved
-            click.echo(MIGRATIONS.keys())
-        else:
-            click.echo(MIGRATIONS[migration][2])
-
-migrations()
-
-class GitError(LookupError):
-    """Raised when a git operation fails, generally due to a missing commit or branch, or network connection issues."""
-    pass
-
-class Git:
-    def __init__(self, path):
-        self.path = path
-        self.base = ("git", "-C", path)
-
-    def get_hash(self, target):
-        try:
-            return subprocess.check_output(self.base + ("show", target, "-s", "--format=format:%H", "--"), stderr=subprocess.DEVNULL).decode("utf-8")
-        except subprocess.CalledProcessError as e:
-            raise GitError from e
-
-    def get_commit_message(self, target):
-        try:
-            return subprocess.check_output(self.base + ("show", target, "-s", "--format=format:%B", "--"), stderr=subprocess.DEVNULL).decode("utf-8", "replace")
-        except subprocess.CalledProcessError as e:
-            raise GitError from e
-
-# Currently we only use one git repo, at cache_home
-GIT = Git(cache_home)
-
-class Repo:
-    def __init__(self, dbconn, project_commit, url, branch, head_commit, list_metadata=False):
-        self.url = url
-        self.branch = branch
-        self.project_commit = project_commit
-        self.erroring = False
-
-        if not branch:
-            self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest()
-            self.head = "HEAD"
-        else:
-            self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest()
-            self.head = "refs/heads/" + branch
-
-        if head_commit:
-            self.hash = head_commit
-        else:
-            try: # FIXME should we even do this?
-                self.hash = GIT.get_hash(self.branchname)
-            except GitError:
-                self.erroring = True
-                self.hash = None
-
-        self.message = None
-        if list_metadata:
-            try:
-                self.update_metadata()
-            except GitError:
-                self.erroring = True
-                pass
-
-    def update_metadata(self):
-        self.message = GIT.get_commit_message(self.branchname)
-
-    def update(self, updating=True):
-        """
-        Updates the git repo, returning new metadata.
-        """
-        if updating:
-            try:
-                subprocess.check_output(["git", "-C", cache_home, "fetch", "-q", self.url, "+" + self.head + ":" + self.branchname], stderr=subprocess.STDOUT)
-            except subprocess.CalledProcessError as e:
-                # This may error for various reasons, but some are important: dead links, etc
-                click.echo(e.output, err=True)
-                self.erroring = True
-                return None
-        pre_hash = self.hash
-        try:
-            post_hash = GIT.get_hash(self.branchname)
-        except GitError as e:
-            # This should never happen, but maybe there's some edge cases?
-            # TODO check
-            self.erroring = True
-            return None
-        self.hash = post_hash
-        if not pre_hash:
-            pre_hash = post_hash
-        try:
-            count = int(subprocess.check_output(["git", "-C", cache_home, "rev-list", "--count", pre_hash + ".." + post_hash, "--"]).decode("utf-8").strip())
-        except subprocess.CalledProcessError:
-            count = 0  # force-pushed
-        try:
-            if updating:
-                subprocess.check_call(["git", "-C", cache_home, "merge-base", "--is-ancestor", self.project_commit, self.branchname], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-            self.update_metadata()
-            return count
-        except (subprocess.CalledProcessError, GitError) as e:
-            click.echo(e, err=True)
-            self.erroring = True
-            return None
-
-class Project:
-    def __init__(self, dbconn, project_commit, list_repos=False):
-        self.commit = project_commit
-        self.refresh_metadata()
-        self.repos = None
-        if list_repos:
-            self.list_repos(dbconn)
-
-    def list_repos(self, dbconn):
-        repos = []
-        with dbconn:
-            for (e, url, branch, head_commit) in dbconn.execute('''SELECT "max"("e"), "url", "branch", "head_commit" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch", "T1"."head_commit" FROM "repo_history" "T1"
-                                                                WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1)
-                                                                GROUP BY "T1"."url", "T1"."branch"
-                                                                UNION
-                                                                SELECT null, "T3"."url", "T3"."branch", null FROM "repos" "T3" WHERE "active" AND "project" IS ?1)
-                                       GROUP BY "url" ORDER BY "e"''', (self.commit,)):
-                repos.append(Repo(dbconn, self.commit, url, branch, head_commit))
-        self.repos = repos
-
-    def refresh_metadata(self):
-        try:
-            project = GIT.get_commit_message(self.commit)
-            project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE))
-            if not project_title.strip(): # FIXME
-                project_title, project_desc = ("Error parsing project commit",)*2
-            # if project_desc: # FIXME
-            #     project_desc = project_desc.strip()
-            self.commit_body = project
-            self.title = project_title
-            self.description = project_desc
-        except GitError:
-            self.commit_body = None
-            self.title = None
-            self.description = None
-
-    def update(self, updating=True):
-        # TODO? check if working correctly
-        results = [(repo, repo.update(updating)) for repo in self.repos]
-        self.refresh_metadata()
-        return results
-
-class GAnarchy:
-    def __init__(self, dbconn, config, list_projects=False, list_repos=False):
-        base_url = config.base_url
-        title = config.title
-        if not base_url:
-            # FIXME use a more appropriate error type
-            raise ValueError
-        if not title:
-            title = "GAnarchy on " + urlparse(base_url).hostname
-        self.title = title
-        self.base_url = base_url
-        # load config onto DB
-        c = dbconn.cursor()
-        c.execute('''CREATE TEMPORARY TABLE "repos" ("url" TEXT PRIMARY KEY, "active" INT, "branch" TEXT, "project" TEXT)''')
-        c.execute('''CREATE UNIQUE INDEX "temp"."repos_url_branch_project" ON "repos" ("url", "branch", "project")''')
-        c.execute('''CREATE INDEX "temp"."repos_project" ON "repos" ("project")''')
-        c.execute('''CREATE INDEX "temp"."repos_active" ON "repos" ("active")''')
-        for (project_commit, repos) in config.projects.items():
-            for (repo_url, branches) in repos.items():
-                for (branchname, options) in branches.items():
-                    if options['active']: # no need to insert inactive repos since they get ignored anyway
-                        c.execute('''INSERT INTO "repos" VALUES (?, ?, ?, ?)''', (repo_url, 1, branchname, project_commit))
-        dbconn.commit()
-        if list_projects:
-            projects = []
-            with dbconn:
-                for (project,) in dbconn.execute('''SELECT DISTINCT "project" FROM "repos" '''):
-                    projects.append(Project(dbconn, project, list_repos=list_repos))
-            projects.sort(key=lambda project: project.title) # sort projects by title
-            self.projects = projects
-        else:
-            self.projects = None
-
-@ganarchy.command()
-@click.argument('out', required=True)
-def run(out):
-    """Runs ganarchy standalone.
-
-    This will run ganarchy so it regularly updates the output directory given by OUT.
-    Additionally, it'll also search for the following hooks in its config dirs:
-
-        - post_object_update_hook - executed after an object is updated.
-
-        - post_update_cycle_hook - executed after all objects in an update cycle are updated."""
-    pass
+#import abc
+#import builtins
+#import hashlib
+#import hmac
+#import os
+#import re
+#import sqlite3
+#import subprocess
+#
+#import click
+#import jinja2
+#import qtoml
+#import requests
+#
+#from collections import defaultdict
+#from urllib.parse import urlparse
+#
+#import ganarchy as m_ganarchy
+#import ganarchy.dirs
+#import ganarchy.git
 
-@ganarchy.command()
-@click.option('--update/--no-update', default=True)
-@click.argument('project', required=False)
-def cron_target(update, project):
-    """Runs ganarchy as a cron target."""
-    conf = None
-    # reverse order is intentional
-    for d in reversed(config_dirs):
-        try:
-            conf = Config(open(d + "/config.toml", 'r', encoding='utf-8', newline=''), conf)
-        except (OSError, UnicodeDecodeError, qtoml.decoder.TOMLDecodeError):
-            pass
-    with open(config_home + "/config.toml", 'r', encoding='utf-8', newline='') as f:
-        conf = Config(f, conf)
-    env = get_env()
-    if project == "config":
-        # render the config
-        # doesn't have access to a GAnarchy object. this is deliberate.
-        template = env.get_template('index.toml')
-        click.echo(template.render(config = conf))
-        return
-    if project == "project-list":
-        # could be done with a template but eh w/e, this is probably better
-        for project in conf.projects.keys():
-            click.echo(project)
-        return
-    # make sure the cache dir exists
-    os.makedirs(cache_home, exist_ok=True)
-    # make sure it is a git repo
-    subprocess.call(["git", "-C", cache_home, "init", "-q"])
-    conn = sqlite3.connect(data_home + "/ganarchy.db")
-    instance = GAnarchy(conn, conf, list_projects=project in ["index", "config"])
-    if project == "index":
-        # render the index
-        template = env.get_template('index.html')
-        click.echo(template.render(ganarchy = instance))
-        return
-    if not instance.base_url or not project:
-        click.echo("No base URL or project commit specified", err=True)
-        return
-    entries = []
-    generate_html = []
-    c = conn.cursor()
-    p = Project(conn, project, list_repos=True)
-    results = p.update(update)
-    for (repo, count) in results:
-        if count is not None:
-            entries.append((repo.url, count, repo.hash, repo.branch, project))
-            generate_html.append((repo.url, repo.message, count, repo.branch))
-    # sort stuff twice because reasons
-    entries.sort(key=lambda x: x[1], reverse=True)
-    generate_html.sort(key=lambda x: x[2], reverse=True)
-    if update:
-        c.executemany('''INSERT INTO "repo_history" ("url", "count", "head_commit", "branch", "project") VALUES (?, ?, ?, ?, ?)''', entries)
-        conn.commit()
-    html_entries = []
-    for (url, msg, count, branch) in generate_html:
-        history = c.execute('''SELECT "count" FROM "repo_history" WHERE "url" = ? AND "branch" IS ? AND "project" IS ? ORDER BY "entry" ASC''', (url, branch, project)).fetchall()
-        # TODO process history into SVG
-        html_entries.append((url, msg, "", branch))
-    template = env.get_template('project.html')
-    click.echo(template.render(project_title  = p.title,
-                               project_desc   = p.description,
-                               project_body   = p.commit_body,
-                               project_commit = p.commit,
-                               repos          = html_entries,
-                               base_url       = instance.base_url,
-                               # I don't think this thing supports deprecating the above?
-                               project        = p,
-                               ganarchy       = instance))
diff --git a/ganarchy/__main__.py b/ganarchy/__main__.py
index c7795ed..1bab5d1 100644
--- a/ganarchy/__main__.py
+++ b/ganarchy/__main__.py
@@ -1,5 +1,5 @@
 # GAnarchy - decentralized project hub
-# Copyright (C) 2019  Soni L.
+# Copyright (C) 2019, 2020  Soni L.
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -21,6 +21,7 @@ import ganarchy.cli
 import ganarchy
 
 # Additional CLI commands
+import ganarchy.cli.db
 import ganarchy.cli.debug
 import ganarchy.cli.merge_configs
 
diff --git a/ganarchy/cli/__init__.py b/ganarchy/cli/__init__.py
index 9effabb..727c48d 100644
--- a/ganarchy/cli/__init__.py
+++ b/ganarchy/cli/__init__.py
@@ -14,6 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
+"""The GAnarchy CLI.
+
+This module just defines the main command group. Submodules define
+actual commands.
+"""
+
 import click
 
 @click.group()
diff --git a/ganarchy/cli/db.py b/ganarchy/cli/db.py
new file mode 100644
index 0000000..e754c34
--- /dev/null
+++ b/ganarchy/cli/db.py
@@ -0,0 +1,71 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+"""Database-related CLI commands.
+
+"""
+
+import os
+
+import click
+
+import ganarchy.cli
+import ganarchy.data
+import ganarchy.db
+import ganarchy.dirs
+
+@ganarchy.cli.main.command()
+def initdb():
+    """Initializes the ganarchy database."""
+    # TODO: makedirs in a separate command?
+    os.makedirs(ganarchy.dirs.DATA_HOME, exist_ok=True)
+    db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default())
+    db.initialize()
+    db.close()
+
+@ganarchy.cli.main.group()
+def migrations():
+    """Modifies the DB to work with a newer/older version.
+
+    WARNING: THIS COMMAND CAN BE EXTREMELY DESTRUCTIVE!"""
+
+@migrations.command()
+@click.argument('migration')
+def apply(migration):
+    """Applies the migration with the given name."""
+    db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default())
+    click.echo(ganarchy.db.MIGRATIONS[migration][0])
+    db.apply_migration(migration)
+    db.close()
+
+@click.argument('migration')
+@migrations.command()
+def revert(migration):
+    """Reverts the migration with the given name."""
+    db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default())
+    click.echo(ganarchy.db.MIGRATIONS[migration][1])
+    db.revert_migration(migration)
+    db.close()
+
+@click.argument('migration', required=False)
+@migrations.command()
+def info(migration):
+    """Shows information about the migration with the given name."""
+    if not migration:
+        # TODO could be improved
+        click.echo(ganarchy.db.MIGRATIONS.keys())
+    else:
+        click.echo(ganarchy.db.MIGRATIONS[migration][2])
diff --git a/ganarchy/cli/run_targets.py b/ganarchy/cli/run_targets.py
new file mode 100644
index 0000000..5bf48d9
--- /dev/null
+++ b/ganarchy/cli/run_targets.py
@@ -0,0 +1,111 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+"""This module contains the CLI Run Targets.
+"""
+
+import click
+
+from ganarchy import cli
+from ganarchy.templating import environment
+
+#@cli.main.command()
+#@click.argument('out', required=True)
+#def run(out):
+#    """Runs ganarchy standalone.
+#
+#    This will run ganarchy so it regularly updates the output directory given by OUT.
+#    Additionally, it'll also search for the following hooks in its config dirs:
+#
+#        - post_object_update_hook - executed after an object is updated.
+#
+#        - post_update_cycle_hook - executed after all objects in an update cycle are updated."""
+#    pass
+
+@ganarchy.command()
+@click.option('--update/--no-update', default=True)
+@click.argument('project', required=False)
+def cron_target(update, project):
+    """Runs ganarchy as a cron target.
+
+    "Deprecated". Useful if you want full control over how GAnarchy
+    generates the pages.
+    """
+    #conf = None
+    ## reverse order is intentional
+    #for d in reversed(config_dirs):
+    #    try:
+    #        conf = Config(open(d + "/config.toml", 'r', encoding='utf-8', newline=''), conf)
+    #    except (OSError, UnicodeDecodeError, qtoml.decoder.TOMLDecodeError):
+    #        pass
+    #with open(config_home + "/config.toml", 'r', encoding='utf-8', newline='') as f:
+    #    conf = Config(f, conf)
+    env = get_env()
+    if project == "config":
+        # render the config
+        # doesn't have access to a GAnarchy object. this is deliberate.
+        template = env.get_template('index.toml')
+        click.echo(template.render(config = conf))
+        return
+    if project == "project-list":
+        # could be done with a template but eh w/e, this is probably better
+        for project in conf.projects.keys():
+            click.echo(project)
+        return
+    # make sure the cache dir exists
+    os.makedirs(cache_home, exist_ok=True)
+    # make sure it is a git repo
+    subprocess.call(["git", "-C", cache_home, "init", "-q"])
+    conn = sqlite3.connect(data_home + "/ganarchy.db")
+    instance = GAnarchy(conn, conf, list_projects=project in ["index", "config"])
+    if project == "index":
+        # render the index
+        template = env.get_template('index.html')
+        click.echo(template.render(ganarchy = instance))
+        return
+    if not instance.base_url or not project:
+        click.echo("No base URL or project commit specified", err=True)
+        return
+    entries = []
+    generate_html = []
+    c = conn.cursor()
+    p = Project(conn, project, list_repos=True)
+    results = p.update(update)
+    for (repo, count) in results:
+        if count is not None:
+            entries.append((repo.url, count, repo.hash, repo.branch, project))
+            generate_html.append((repo.url, repo.message, count, repo.branch))
+    # sort stuff twice because reasons
+    entries.sort(key=lambda x: x[1], reverse=True)
+    generate_html.sort(key=lambda x: x[2], reverse=True)
+    if update:
+        c.executemany('''INSERT INTO "repo_history" ("url", "count", "head_commit", "branch", "project") VALUES (?, ?, ?, ?, ?)''', entries)
+        conn.commit()
+    html_entries = []
+    for (url, msg, count, branch) in generate_html:
+        history = c.execute('''SELECT "count" FROM "repo_history" WHERE "url" = ? AND "branch" IS ? AND "project" IS ? ORDER BY "entry" ASC''', (url, branch, project)).fetchall()
+        # TODO process history into SVG
+        html_entries.append((url, msg, "", branch))
+    template = env.get_template('project.html')
+    click.echo(template.render(project_title  = p.title,
+                               project_desc   = p.description,
+                               project_body   = p.commit_body,
+                               project_commit = p.commit,
+                               repos          = html_entries,
+                               base_url       = instance.base_url,
+                               # I don't think this thing supports deprecating the above?
+                               project        = p,
+                               ganarchy       = instance))
diff --git a/ganarchy/core.py b/ganarchy/core.py
new file mode 100644
index 0000000..cf5edd4
--- /dev/null
+++ b/ganarchy/core.py
@@ -0,0 +1,165 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import ganarchy.git
+import ganarchy.dirs
+
+# Currently we only use one git repo, at CACHE_HOME
+# TODO optimize
+GIT = ganarchy.git.Git(ganarchy.dirs.CACHE_HOME)
+
+class Repo:
+    def __init__(self, dbconn, project_commit, url, branch, head_commit, list_metadata=False):
+        self.url = url
+        self.branch = branch
+        self.project_commit = project_commit
+        self.erroring = False
+
+        if not branch:
+            self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest()
+            self.head = "HEAD"
+        else:
+            self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest()
+            self.head = "refs/heads/" + branch
+
+        if head_commit:
+            self.hash = head_commit
+        else:
+            try: # FIXME should we even do this?
+                self.hash = GIT.get_hash(self.branchname)
+            except ganarchy.git.GitError:
+                self.erroring = True
+                self.hash = None
+
+        self.message = None
+        if list_metadata:
+            try:
+                self.update_metadata()
+            except ganarchy.git.GitError:
+                self.erroring = True
+                pass
+
+    def update_metadata(self):
+        self.message = GIT.get_commit_message(self.branchname)
+
+    def update(self, updating=True):
+        """Updates the git repo, returning new metadata.
+        """
+        if updating:
+            try:
+                GIT.force_fetch(self.url, self.head, self.branchname)
+            except ganarchy.git.GitError as e:
+                # This may error for various reasons, but some
+                # are important: dead links, etc
+                click.echo(e.output, err=True)
+                self.erroring = True
+                return None
+        pre_hash = self.hash
+        try:
+            post_hash = GIT.get_hash(self.branchname)
+        except ganarchy.git.GitError as e:
+            # This should never happen, but maybe there's some edge cases?
+            # TODO check
+            self.erroring = True
+            return None
+        self.hash = post_hash
+        if not pre_hash:
+            pre_hash = post_hash
+        count = GIT.get_count(pre_hash, post_hash)
+        try:
+            if updating:
+                GIT.check_history(self.branchname, self.project_commit)
+            self.update_metadata()
+            return count
+        except ganarchy.git.GitError as e:
+            click.echo(e, err=True)
+            self.erroring = True
+            return None
+
+class Project:
+    def __init__(self, dbconn, project_commit, list_repos=False):
+        self.commit = project_commit
+        self.refresh_metadata()
+        self.repos = None
+        if list_repos:
+            self.list_repos(dbconn)
+
+    def list_repos(self, dbconn):
+        repos = []
+        with dbconn:
+            for (e, url, branch, head_commit) in dbconn.execute('''SELECT "max"("e"), "url", "branch", "head_commit" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch", "T1"."head_commit" FROM "repo_history" "T1"
+                                                                WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1)
+                                                                GROUP BY "T1"."url", "T1"."branch"
+                                                                UNION
+                                                                SELECT null, "T3"."url", "T3"."branch", null FROM "repos" "T3" WHERE "active" AND "project" IS ?1)
+                                       GROUP BY "url" ORDER BY "e"''', (self.commit,)):
+                repos.append(Repo(dbconn, self.commit, url, branch, head_commit))
+        self.repos = repos
+
+    def refresh_metadata(self):
+        try:
+            project = GIT.get_commit_message(self.commit)
+            project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE))
+            if not project_title.strip(): # FIXME
+                project_title, project_desc = ("Error parsing project commit",)*2
+            # if project_desc: # FIXME
+            #     project_desc = project_desc.strip()
+            self.commit_body = project
+            self.title = project_title
+            self.description = project_desc
+        except ganarchy.git.GitError:
+            self.commit_body = None
+            self.title = None
+            self.description = None
+
+    def update(self, updating=True):
+        # TODO? check if working correctly
+        results = [(repo, repo.update(updating)) for repo in self.repos]
+        self.refresh_metadata()
+        return results
+
+class GAnarchy:
+    def __init__(self, dbconn, config, list_projects=False, list_repos=False):
+        base_url = config.base_url
+        title = config.title
+        if not base_url:
+            # FIXME use a more appropriate error type
+            raise ValueError
+        if not title:
+            title = "GAnarchy on " + urlparse(base_url).hostname
+        self.title = title
+        self.base_url = base_url
+        # load config onto DB
+        c = dbconn.cursor()
+        c.execute('''CREATE TEMPORARY TABLE "repos" ("url" TEXT PRIMARY KEY, "active" INT, "branch" TEXT, "project" TEXT)''')
+        c.execute('''CREATE UNIQUE INDEX "temp"."repos_url_branch_project" ON "repos" ("url", "branch", "project")''')
+        c.execute('''CREATE INDEX "temp"."repos_project" ON "repos" ("project")''')
+        c.execute('''CREATE INDEX "temp"."repos_active" ON "repos" ("active")''')
+        for (project_commit, repos) in config.projects.items():
+            for (repo_url, branches) in repos.items():
+                for (branchname, options) in branches.items():
+                    if options['active']: # no need to insert inactive repos since they get ignored anyway
+                        c.execute('''INSERT INTO "repos" VALUES (?, ?, ?, ?)''', (repo_url, 1, branchname, project_commit))
+        dbconn.commit()
+        if list_projects:
+            projects = []
+            with dbconn:
+                for (project,) in dbconn.execute('''SELECT DISTINCT "project" FROM "repos" '''):
+                    projects.append(Project(dbconn, project, list_repos=list_repos))
+            projects.sort(key=lambda project: project.title) # sort projects by title
+            self.projects = projects
+        else:
+            self.projects = None
diff --git a/ganarchy/data.py b/ganarchy/data.py
index 7de0764..730a5a9 100644
--- a/ganarchy/data.py
+++ b/ganarchy/data.py
@@ -1,5 +1,5 @@
 # This file is part of GAnarchy - decentralized project hub
-# Copyright (C) 2019  Soni L.
+# Copyright (C) 2019, 2020  Soni L.
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -35,6 +35,8 @@ import requests
 from enum import Enum
 from urllib.parse import urlparse
 
+import ganarchy.dirs
+
 # TODO move elsewhere
 class URIPredicate(abdl.predicates.Predicate):
     def __init__(self, ports=range(1,65536), schemes=('https',)):
@@ -391,8 +393,7 @@ class ConfigManager(DataSource):
 
     @classmethod
     def new_default(cls):
-        from ganarchy import config_home, config_dirs
-        srcs = [LocalDataSource(d + "/config.toml") for d in [config_home] + config_dirs]
+        srcs = [LocalDataSource(d + "/config.toml") for d in [ganarchy.dirs.CONFIG_HOME] + ganarchy.dirs.CONFIG_DIRS]
         return cls(srcs + [DefaultsDataSource()])
 
     def exists(self):
diff --git a/ganarchy/db.py b/ganarchy/db.py
new file mode 100644
index 0000000..f16081a
--- /dev/null
+++ b/ganarchy/db.py
@@ -0,0 +1,336 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+"""This module handles GAnarchy's database.
+
+Attributes:
+    MIGRATIONS: Migrations.
+"""
+
+import sqlite3
+
+import ganarchy.dirs
+import ganarchy.data
+
+# FIXME this should not be used directly because it's a pain.
+MIGRATIONS = {
+        "toml-config": (
+                (
+                    '''UPDATE "repo_history"
+                    SET "project" = (SELECT "git_commit" FROM "config")
+                    WHERE "project" IS NULL''',
+
+                    '''ALTER TABLE "repos"
+                    RENAME TO "repos_old"''',
+                ),
+                (
+                    '''UPDATE "repo_history"
+                    SET "project" = NULL
+                    WHERE "project" = (SELECT "git_commit" FROM "config")''',
+
+                    '''ALTER TABLE "repos_old"
+                    RENAME TO "repos"''',
+                ),
+                "switches to toml config format. the old 'repos' " #cont
+                "table is preserved as 'repos_old'"
+            ),
+        "better-project-management": (
+                (
+                    '''ALTER TABLE "repos"
+                    ADD COLUMN "branch" TEXT''',
+
+                    '''ALTER TABLE "repos"
+                    ADD COLUMN "project" TEXT''',
+
+                    '''CREATE UNIQUE INDEX "repos_url_branch_project"
+                    ON "repos" ("url", "branch", "project")''',
+
+                    '''CREATE INDEX "repos_project"
+                    ON "repos" ("project")''',
+
+                    '''ALTER TABLE "repo_history"
+                    ADD COLUMN "branch" TEXT''',
+
+                    '''ALTER TABLE "repo_history"
+                    ADD COLUMN "project" TEXT''',
+
+                    '''CREATE INDEX "repo_history_url_branch_project"
+                    ON "repo_history" ("url", "branch", "project")''',
+                ),
+                (
+                    '''DELETE FROM "repos"
+                    WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',
+                    '''DELETE FROM "repo_history"
+                    WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',
+                ),
+                "supports multiple projects, and allows choosing " #cont
+                "non-default branches"
+            ),
+        "test": (
+                (
+                    '''-- apply''',
+                ),
+                (
+                    '''-- revert''',
+                ),
+                "does nothing"
+            )
+        }
+
+class Database:
+    """A database connection/session, returned by ``connect_database``.
+
+    Some methods may require repos to be loaded.
+    """
+
+    def __init__(self, conn):
+        self.conn = conn
+
+    def initialize(self):
+        """Initializes the database tables as expected by GAnarchy.
+        """
+        c = self.conn.cursor()
+        c.execute('''
+            CREATE TABLE "repo_history" (
+                "entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT,
+                "url" TEXT,
+                "count" INTEGER,
+                "head_commit" TEXT,
+                "branch" TEXT,
+                "project" TEXT
+            )
+        ''')
+        c.execute('''
+            CREATE INDEX "repo_history_url_branch_project"
+            ON "repo_history" ("url", "branch", "project")
+        ''')
+        self.conn.commit()
+        c.close()
+
+    def apply_migration(self, migration):
+        """Applies a migration, by name.
+
+        WARNING: Destructive operation.
+
+        Args:
+            migration (str): The name of the migration.
+        """
+        c = self.conn.cursor()
+        for migration in MIGRATIONS[migration][0]:
+            c.execute(migration)
+        self.conn.commit()
+        c.close()
+
+    def revert_migration(self, migration):
+        """Reverts a previously-applied migration, by name.
+
+        WARNING: Destructive operation.
+
+        Args:
+            migration (str): The name of the migration.
+        """
+        c = self.conn.cursor()
+        for migration in MIGRATIONS[migration][1]:
+            c.execute(migration)
+        self.conn.commit()
+        c.close()
+
+    def load_repos(self, effective_repo_list):
+        """Loads repos from repo list.
+
+        Must be done once for each instance of Database.
+
+        Args:
+            effective_repo_list (ganarchy.data.DataSource): The data
+            source for the repo list.
+        """
+        c = self.conn.cursor()
+        c.execute('''
+            CREATE TEMPORARY TABLE "repos" (
+                "url" TEXT PRIMARY KEY,
+                "active" INT,
+                "branch" TEXT,
+                "project" TEXT
+            )
+        ''')
+        c.execute('''
+            CREATE UNIQUE INDEX "temp"."repos_url_branch_project"
+            ON "repos" ("url", "branch", "project")
+        ''')
+        c.execute('''
+            CREATE INDEX "temp"."repos_project"
+            ON "repos" ("project")
+        ''')
+        c.execute('''
+            CREATE INDEX "temp"."repos_active"
+            ON "repos" ("active")
+        ''')
+        for repo in effective_repo_list.get_property_values(
+            ganarchy.data.DataProperty.VCS_REPOS
+        ):
+            if repo.active:
+                c.execute(
+                    '''INSERT INTO "repos" VALUES (?, ?, ?, ?)''',
+                    (repo.uri, 1, repo.branch, repo.project_commit)
+                )
+        self.conn.commit()
+        c.close()
+
+    def insert_activity(self, project_commit, uri, branch, head, count):
+        """Inserts activity of a repo-branch.
+
+        Args:
+            project_commit: The project commit.
+            uri: The repo uri.
+            branch: The branch.
+            head: The latest known head commit.
+            count: The number of new commits.
+        """
+        self.insert_activities([(project_commit, uri, branch, head, count)])
+
+    def insert_activities(self, activities):
+        """Inserts activities of repo-branches.
+
+        Args:
+            activities: List of tuple. The tuple must match up with the
+            argument order specified by ``insert_activity``.
+        """
+        c = self.conn.cursor()
+        c.executemany(
+            '''
+                INSERT INTO "repo_history" (
+                    "project",
+                    "url",
+                    "branch",
+                    "head_commit",
+                    "count"
+                )
+                VALUES (?, ?, ?, ?, ?)
+            ''',
+            activities
+        )
+        conn.commit()
+        c.close()
+
+    def list_projects(self):
+        """Lists loaded projects.
+
+        Repos must be loaded first.
+
+        Yields:
+            str: Project commit of each project.
+        """
+        c = self.conn.cursor()
+        try:
+            for (project,) in c.execute(
+                '''SELECT DISTINCT "project" FROM "repos" '''
+            ):
+                yield project
+        finally:
+            c.close()
+
+    def list_repobranches(self, project_commit):
+        """Lists repo-branches of a project.
+
+        Repos must be loaded first.
+
+        Results are sorted by recent activity.
+
+        Args:
+            project_commit: The project commit.
+
+        Yields:
+            A 3-tuple holding the URI, branch name, and last known head
+            commit.
+        """
+        c = self.conn.cursor()
+        try:
+            for (e, url, branch, head_commit) in c.execute(
+                '''
+                    SELECT "max"("e"), "url", "branch", "head_commit"
+                    FROM (
+                        SELECT
+                            "max"("T1"."entry") "e",
+                            "T1"."url",
+                            "T1"."branch",
+                            "T1"."head_commit"
+                        FROM "repo_history" "T1"
+                        WHERE (
+                            SELECT "active"
+                            FROM "repos" "T2"
+                            WHERE
+                                "url" = "T1"."url"
+                                AND "branch" IS "T1"."branch"
+                                AND "project" IS ?1
+                        )
+                        GROUP BY "T1"."url", "T1"."branch"
+                        UNION
+                        SELECT null, "T3"."url", "T3"."branch", null
+                        FROM "repos" "T3"
+                        WHERE "active" AND "project" IS ?1
+                    )
+                    GROUP BY "url"
+                    ORDER BY "e"
+                ''',
+                (project_commit,)
+            ):
+                yield url, branch, head_commit
+        finally:
+            c.close()
+
+    def list_repobranch_activity(self, project_commit, uri, branch):
+        """Lists activity of a repo-branch.
+
+        Args:
+            project_commit: The project commit.
+            uri: The repo uri.
+            branch: The branch.
+
+        Returns:
+            list of int: Number of commits between updates.
+        """
+        c = self.conn.cursor()
+        history = c.execute(
+            '''
+                SELECT "count"
+                FROM "repo_history"
+                WHERE
+                    "url" = ?
+                    AND "branch" IS ?
+                    AND "project" IS ?
+                ORDER BY "entry" ASC
+            ''',
+            (url, branch, project)
+        ).fetchall()
+        history = [x for [x] in history]
+        c.close()
+        return history
+
+    def close(self):
+        """Closes the database.
+        """
+        self.conn.close()
+
+def connect_database(effective_config):
+    """Opens the database specified by the given config.
+
+    Args:
+        effective_config (ganarchy.data.DataSource): The data source
+        for the config.
+    """
+    del effective_config  # currently unused, intended for the future
+    conn = sqlite3.connect(ganarchy.dirs.data_home + "/ganarchy.db")
+    return Database(conn)
diff --git a/ganarchy/dirs.py b/ganarchy/dirs.py
new file mode 100644
index 0000000..7973126
--- /dev/null
+++ b/ganarchy/dirs.py
@@ -0,0 +1,53 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+"""This module handles GAnarchy's config, data and cache directories.
+
+These are not XDG dirs. They're GAnarchy dirs. They're based on XDG
+dirs but they're not XDG dirs.
+
+Attributes:
+    DATA_HOME (str): GAnarchy data home.
+    CACHE_HOME (str): GAnarchy cache home.
+    CONFIG_HOME (str): GAnarchy config home.
+    CONFIG_DIRS (list of str): GAnarchy config dirs.
+"""
+
+import os
+
+# need to check for unset or empty, ``.get`` only handles unset.
+
+DATA_HOME = os.environ.get('XDG_DATA_HOME', '')
+if not DATA_HOME:
+    DATA_HOME = os.environ['HOME'] + '/.local/share'
+DATA_HOME = DATA_HOME + "/ganarchy"
+
+CACHE_HOME = os.environ.get('XDG_CACHE_HOME', '')
+if not CACHE_HOME:
+    CACHE_HOME = os.environ['HOME'] + '/.cache'
+CACHE_HOME = CACHE_HOME + "/ganarchy"
+
+CONFIG_HOME = os.environ.get('XDG_CONFIG_HOME', '')
+if not CONFIG_HOME:
+    CONFIG_HOME = os.environ['HOME'] + '/.config'
+CONFIG_HOME = CONFIG_HOME + "/ganarchy"
+
+CONFIG_DIRS = os.environ.get('XDG_CONFIG_DIRS', '')
+if not CONFIG_DIRS:
+    CONFIG_DIRS = '/etc/xdg'
+# TODO check if this is correct
+CONFIG_DIRS = [config_dir + "/ganarchy" for config_dir in CONFIG_DIRS.split(':')]
+
diff --git a/ganarchy/git.py b/ganarchy/git.py
new file mode 100644
index 0000000..25be04a
--- /dev/null
+++ b/ganarchy/git.py
@@ -0,0 +1,131 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+"""Git abstraction.
+"""
+
+# Errors are raised when we can't provide an otherwise valid result.
+# For example, we return 0 for counts instead of raising, but raise
+# instead of returning empty strings for commit hashes and messages.
+
+import subprocess
+
+class GitError(LookupError):
+    """Raised when a git operation fails, generally due to a
+    missing commit or branch, or network connection issues.
+    """
+    pass
+
+class Git:
+    def __init__(self, path):
+        self.path = path
+        self.base = ("git", "-C", path)
+
+    def check_history(self, local_head, commit):
+        """Checks if the local head contains commit in its history.
+        Raises if it doesn't.
+
+        Args:
+            local_head (str): Name of local head.
+            commit (str): Commit hash.
+
+        Raises:
+            GitError: If an error occurs.
+        """
+        try:
+            subprocess.check_call(
+                self.base + ("merge-base", "--is-ancestor", commit, local_head),
+                stdout=subprocess.DEVNULL,
+                stderr=subprocess.DEVNULL
+            )
+        except subprocess.CalledProcessError as e:
+            raise GitError from e
+
+    def force_fetch(self, url, remote_head, local_head):
+        """Fetches a remote head into a local head.
+        
+        If the local head already exists, it is replaced.
+
+        Args:
+            url (str): Remote url.
+            remote_head (str): Name of remote head.
+            local_head (str): Name of local head.
+
+        Raises:
+            GitError: If an error occurs.
+        """
+        try:
+            subprocess.check_output(
+                base + ("fetch", "-q", url, "+" + remote_head + ":" + local_head),
+                stderr=subprocess.STDOUT
+            )
+        except subprocess.CalledProcessError as e:
+            raise GitError from e
+
+    def get_count(self, first_hash, last_hash):
+        """Returns a count of the commits added since ``first_hash``
+        up to ``last_hash``.
+
+        Args:
+            first_hash (str): A commit.
+            last_hash (str): Another commit.
+
+        Returns:
+            int: A count of commits added between the hashes, or 0
+            if an error occurs.
+        """
+        try:
+            res = subprocess.check_output(
+                base + ("rev-list", "--count", first_hash + ".." + last_hash, "--"),
+                stderr=subprocess.DEVNULL
+            ).decode("utf-8").strip()
+            return int(res)
+        except subprocess.CalledProcessError as e:
+            return 0
+
+    def get_hash(self, target):
+        """Returns the commit hash for a given target.
+
+        Args:
+            target (str): a refspec.
+
+        Raises:
+            GitError: If an error occurs.
+        """
+        try:
+            return subprocess.check_output(
+                self.base + ("show", target, "-s", "--format=format:%H", "--"),
+                stderr=subprocess.DEVNULL
+            ).decode("utf-8")
+        except subprocess.CalledProcessError as e:
+            raise GitError from e
+
+    def get_commit_message(self, target):
+        """Returns the commit message for a given target.
+
+        Args:
+            target (str): a refspec.
+
+        Raises:
+            GitError: If an error occurs.
+        """
+        try:
+            return subprocess.check_output(
+                self.base + ("show", target, "-s", "--format=format:%B", "--"),
+                stderr=subprocess.DEVNULL
+            ).decode("utf-8", "replace")
+        except subprocess.CalledProcessError as e:
+            raise GitError from e
diff --git a/ganarchy/templating/__init__.py b/ganarchy/templating/__init__.py
new file mode 100644
index 0000000..d6f6d0c
--- /dev/null
+++ b/ganarchy/templating/__init__.py
@@ -0,0 +1,21 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+"""Templates.
+
+"""
+
+# TODO write me
diff --git a/ganarchy/templating/environment.py b/ganarchy/templating/environment.py
new file mode 100644
index 0000000..a527053
--- /dev/null
+++ b/ganarchy/templating/environment.py
@@ -0,0 +1,29 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import jinja2
+
+import ganarchy.templating.templates
+import ganarchy.templating.toml
+
+def get_env():
+    env = jinja2.Environment(
+        loader=ganarchy.templating.templates.get_template_loader(),
+        autoescape=False
+    )
+    env.filters['tomlescape'] = ganarchy.templating.toml.tomlescape
+    env.filters['tomle'] = env.filters['tomlescape']
+    return env
diff --git a/ganarchy/templating/templates.py b/ganarchy/templating/templates.py
new file mode 100644
index 0000000..0f691e0
--- /dev/null
+++ b/ganarchy/templating/templates.py
@@ -0,0 +1,124 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import jinja2
+
+import ganarchy.dirs
+
+def get_template_loader():
+    return jinja2.ChoiceLoader([
+        jinja2.FileSystemLoader([ganarchy.dirs.CONFIG_HOME + "/templates"] + [config_dir + "/templates" for config_dir in ganarchy.dirs.CONFIG_DIRS]),
+        jinja2.DictLoader({
+            ## index.html
+            'index.html': """<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <!--
+        GAnarchy - project homepage generator
+        Copyright (C) 2019  Soni L.
+
+        This program is free software: you can redistribute it and/or modify
+        it under the terms of the GNU General Public License as published by
+        the Free Software Foundation, either version 3 of the License, or
+        (at your option) any later version.
+
+        This program is distributed in the hope that it will be useful,
+        but WITHOUT ANY WARRANTY; without even the implied warranty of
+        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+        GNU General Public License for more details.
+
+        You should have received a copy of the GNU General Public License
+        along with this program.  If not, see <https://www.gnu.org/licenses/>.
+        -->
+        <title>{{ ganarchy.title|e }}</title>
+        <meta name="description" content="{{ ganarchy.title|e }}" />
+        <!--if your browser doesn't like the following, use a different browser.-->
+        <script type="application/javascript" src="/index.js"></script>
+    </head>
+    <body>
+        <h1>{{ ganarchy.title|e }}</h1>
+        <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p>
+        <ul>
+        {% for project in ganarchy.projects -%}
+            <li><a href="/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li>
+        {% endfor -%}
+        </ul>
+        <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
+        <p>
+            <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
+        </p>
+    </body>
+</html>
+""",
+            ## index.toml
+            'index.toml': """# Generated by GAnarchy
+
+{%- for project, repos in config.projects.items() %}
+[projects.{{project}}]
+{%- for repo_url, branches in repos.items() %}{% for branch, options in branches.items() %}{% if options.active %}
+"{{repo_url|tomle}}".{% if branch %}"{{branch|tomle}}"{% else %}HEAD{% endif %} = { active=true }
+{%- endif %}{% endfor %}
+{%- endfor %}
+{% endfor -%}
+""",
+            ## project.html FIXME
+            'project.html': """<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <!--
+        GAnarchy - project homepage generator
+        Copyright (C) 2019  Soni L.
+
+        This program is free software: you can redistribute it and/or modify
+        it under the terms of the GNU General Public License as published by
+        the Free Software Foundation, either version 3 of the License, or
+        (at your option) any later version.
+
+        This program is distributed in the hope that it will be useful,
+        but WITHOUT ANY WARRANTY; without even the implied warranty of
+        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+        GNU General Public License for more details.
+
+        You should have received a copy of the GNU General Public License
+        along with this program.  If not, see <https://www.gnu.org/licenses/>.
+        -->
+        <title>{{ project_title|e }}</title>
+        {% if project_desc %}<meta name="description" content="{{ project_desc|e }}" />{% endif %}
+        <style type="text/css">.branchname { color: #808080; font-style: italic; }</style>
+    </head>
+    <body>
+        <h1>{{ project_title|e }}</h1>
+        <p>Tracking <span id="project_commit"><a href="web+ganarchy:{{ project_commit }}">{{ project_commit }}</a></span></p>
+        <div id="project_body"><p>{{ project_body|e|replace("\n\n", "</p><p>") }}</p></div>
+        <ul>
+        {% for url, msg, img, branch in repos -%}
+            <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li>
+        {% endfor -%}
+        </ul>
+        <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
+        <p>
+            <a href="/">Main page</a>.
+            <a href="{{ base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
+        </p>
+    </body>
+</html>
+""",
+            ## history.svg FIXME
+            'history.svg': """""",
+        })
+    ])
diff --git a/ganarchy/templating/toml.py b/ganarchy/templating/toml.py
new file mode 100644
index 0000000..431125d
--- /dev/null
+++ b/ganarchy/templating/toml.py
@@ -0,0 +1,33 @@
+# This file is part of GAnarchy - decentralized project hub
+# Copyright (C) 2020  Soni L.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+_tomletrans = str.maketrans({
+    0: '\\u0000', 1: '\\u0001', 2: '\\u0002', 3: '\\u0003', 4: '\\u0004',
+    5: '\\u0005', 6: '\\u0006', 7: '\\u0007', 8: '\\b', 9: '\\t', 10: '\\n',
+    11: '\\u000B', 12: '\\f', 13: '\\r', 14: '\\u000E', 15: '\\u000F',
+    16: '\\u0010', 17: '\\u0011', 18: '\\u0012', 19: '\\u0013', 20: '\\u0014',
+    21: '\\u0015', 22: '\\u0016', 23: '\\u0017', 24: '\\u0018', 25: '\\u0019',
+    26: '\\u001A', 27: '\\u001B', 28: '\\u001C', 29: '\\u001D', 30: '\\u001E',
+    31: '\\u001F', '"': '\\"', '\\': '\\\\'
+    })
+
+def tomlescape(value):
+    """Escapes a string for use in a TOML string.
+
+    Returns:
+        str: The escaped string.
+    """
+    return value.translate(_tomletrans)