Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

63 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

📋 tl — a social network built on top of git

timeline — your life, versioned. Every post is a commit. Every friend is a submodule. Git is the protocol, and the world is your repository.

tl is a command-line application that turns a plain git repository into a micro-blogging platform. There is no server to deploy, no database to tune, and no proprietary format to lock you in — just git, the same tool that has been versioning human knowledge for decades.

🚀 How it works

The core idea is beautifully simple:

  • Every post is a commit. The commit message is your post content. Attach a document or image and it becomes the commit's content.
  • Every friend is a submodule. Following someone clones their timeline repository into a FOLLOW/ folder inside your own timeline.
  • Every identity is a hash. Users and folders are identified by the SHA3-256 digest of their repository URL, so you never collide with someone else's username.
  • Many repos, one you. One user can hold several repositories, each named with a friendly alias for its SHA3 id. Commands pick a repository with --repo <NAME> and default to the first one created.
  • Aliases are only for you. You can attach friendly aliases to the people you follow — they live in your config file and are never pushed to any timeline.

📁 Storage layout

Everything lives under the tl/ directory, following the XDG Base Directory Specification:

$XDG_DATA_HOME/tl/
├── cache/                 # data that must never be committed
│   └── messages/          #   plaintext of sent direct messages
├── keyring/              # keys managed by tl
│   ├── gpg/              #   public OpenPGP keys imported from followed timelines
│   ├── pqp/              #   public post-quantum keys (keyring-path for `pqp`)
│   └── private/          #   private keys imported by YOU (never in a repo)
│       ├── gpg/          #       OpenPGP private keyring
│       └── pqp/          #       post-quantum private keyring
└── repos/
    └── <name>/              # one folder per repository
        └── timeline/        # the repository timeline (a git repository)
            ├── FOLLOW/      # submodules of the users you follow
            │   └── <sha3-of-url>/   # one folder per followed user
            ├── ATTACH/<post>/       # files attached to a post
            ├── REPOST/<commit>/     # encapsulated original of a repost
            │   └── original        # the raw shared commit object
            ├── PUBKEY              # your public key (from `tl crypto`)
            ├── PQPKEY              #   post-quantum copy of the public key
            └── PGPKEY              #   OpenPGP copy of the public key

$XDG_CONFIG_HOME/tl/
└── tl.toml                  # repositories, followed users, and aliases

Direct messages live on git branches, never in the mainline commits: your timeline keeps one refs/heads/<sha3-of-user> branch per followed user (the messages you sent them), and each followed timeline keeps a refs/heads/<your-sha3> branch with the messages they addressed to you.

🔧 Usage

⚙️ Create a timeline

$ tl create --repo main --pull-url https://example.com/alice/timeline.git \
            --push-url  git@example.com:alice/timeline.git
timeline `main` created at `/home/alice/.local/share/tl/repos/main/timeline`

The --repo flag names the repository (default: default). The first one you create becomes the default, used by every other command unless --repo is given. Pull and push URLs are required on the command line.

create also sets a repository-local git identity derived from your user id (the SHA3 of the pull URL), so posting works even if you have no global git configuration.

📜 Follow someone

$ tl follow https://example.com/bob/timeline.git --alias bob
now following `bob` (https://example.com/bob/timeline.git)

A git submodule is added at FOLLOW/<sha3-of-url> and the follow is recorded as a commit in your timeline, so you can pull updates with a single command. In tl list the follow is shown as the followed user's alias (or short SHA3 id) together with their URL — not the literal word follow.

$ tl follow https://example.com/carol.git --repo work   # follow in another repo

🧭 Discover people to follow

Follows are public: every timeline records who it follows in its .gitmodules. tl discover walks that graph — the users you follow (distance 1), the users they follow (distance 2), and so on — and lists every repository it finds with its URL, the last time it was updated, and its distance from you:

$ tl discover
URL                          LAST UPDATED         DIST
https://example.com/bob.git  2026-08-05 18:20:11  1
https://example.com/carol.git 2026-07-30 09:04:42  2

The whole tree is traversed by default. Bound it with --depth <N> (1 is a direct follow, 2 a follow of a follow, …) and cap the output with --limit <N>. Repositories you already follow are read from their checked-out submodule; repositories deeper in the tree are fetched with a shallow clone into a temporary directory that is removed afterwards. Each repository is visited once, so follow cycles never loop.

🔐 Manage your keys

Give your repository an asymmetric keypair — the public key is what friends use to send you encrypted messages. Pick a post-quantum key (ML-DSA / ML-KEM) with --pqp, or a classic OpenPGP key with --gpg, and generate it:

$ tl crypto --pqp --generate
generated `pqp` keypair for repository `default`; public key published in `PUBKEY`

The private key is stored in the tl-owned keyring under the data directory (keyring/private/pqp or keyring/private/gpg); the public key is committed as PUBKEY (plus PQPKEY/PGPKEY) at the root of your timeline, so anyone who follows you can grab it with tl sync and encrypt messages to you. The commit shows up in tl list with its own kind:

$ tl list
1.  2026-08-05 10:00:00  alice  🔑  a1b2c3d4e5f6a7b8

Without --generate the command reports whether your keys exist and what they contain:

$ tl crypto --pqp
repo:         default
format:       pqp
program:      pqp
private key:  present
  key id:     a1b2c3d4e5f6a7b8
  user id:    1267de09c2d2285216ba2ab9621b7bcbbe97924cff29d496ec6ec3e4271e8a9c
  created:    2026-08-05T10:00:00+00:00
  algorithm:  ML-KEM-768 + ML-DSA-65
  fingerprint: f1f2f3f4f5f6f7f8
public key:   published

Exactly one of --pqp or --gpg is required. --repo selects the repository as usual.

📝 Post a message

$ tl post "hello world"
posted: hello world

Attach files to a post and they are committed together with the message:

$ tl post "check this out" --attach photo.jpg --attach notes.txt
posted: check this out

Attachments are copied into ATTACH/<post>/ inside your timeline, so the post carries its own content — a commit with the message and the files.

If the message contains a mention of a user you follow (matched by the regex from --regex-mention, default @\w+ — the same default as tl list --regex-mention) and the typed value matches one of the followed user's aliases, the mention is rewritten to the user's sha3 id in the commit subject, so the stored post always references the canonical id:

$ tl follow https://example.com/bob/timeline.git --alias bob
$ tl post "hi @bob"
posted: hi @1267de09c2d2285216ba2ab9621b7bcbbe97924cff29d496ec6ec3e4271e8a9c

Unknown mentions (or mentions of users you do not follow) are left untouched. The regex can be customized via --regex-mention or the [post] regex_mention config value.

💬 Reply to a post

Reply to any message shown by tl list, using its sequential id:

$ tl reply 3 "thanks, that clears it up"
replied to 7e047ff8...:35880afb...: thanks, that clears it up

The reply is committed as reply <parent-id> <text> — where <parent-id> is the unique id of the message being answered (sha3(repo_url):<commit>) — and tl list threads it directly below its parent. Replies support --attach too, and can be signed with --sign / --signer / --sign-tool exactly like posts (see 🛡️ Sign and verify posts).

🔁 Repost a message

Share any message shown by tl list on your own timeline:

$ tl repost 4
reposted 7e047ff8...:35880afb...: thanks, that clears it up

The repost is committed as repost <id> <text>, where <id> is the unique id of the shared message. The original message is encapsulated verbatim: its raw commit object — including its gpgsig signature header, if any — is copied into REPOST/<commit>/original inside your timeline, so the shared content and its signature stay verifiable forever. tl verify on a repost checks the encapsulated original, not the (possibly unsigned) repost commit. Reposts can also be signed themselves with --sign / --signer / --sign-tool (see 🛡️ Sign and verify posts).

🔒 Send an encrypted message

Send a private, end-to-end encrypted message to anyone you follow — no plaintext ever touches the mainline of a timeline:

$ tl message bob "did you see the announcement?"
encrypted message sent to `bob` on branch `3f9a2c07`

tl encrypts your text with the recipient's public key — published as a PQPKEY (post-quantum) or PGPKEY (OpenPGP) file in the root of their timeline — and commits the ciphertext to a branch named with the recipient's SHA3 id inside your own timeline:

$ tl git log --format=%s refs/heads/3f9a2c07...
message c52f5b08... Q0lQSEVSRUQ=

The branch is delivered by tl sync. Recipients are named by alias, SHA3 id, or pull URL. You cannot message someone you don't follow, and sending fails if the recipient publishes no public key. Secret keys never live in a repository: import your private key into the keyring/private/ folder by hand (see the storage layout above) to be able to read messages addressed to you.

Attach files with --attach (repeatable) — each one is encrypted with the recipient's public key, just like the message text, and stored in the message commit's tree under ATTACH/<name>:

$ tl message bob "the docs" --attach proposal.pdf --attach notes.txt
encrypted message sent to `bob` on branch `3f9a2c07`

No plaintext file is ever committed: the branch only ever holds encrypted blobs.

The ciphertext is all that is ever committed. To show you your own words later (you have no private key for the recipient's message), tl caches the plaintext in cache/messages/ inside the tl data directory — outside any repository — so tl list always shows your sent messages as plaintext and never dumps the ciphertext.

⚠️ Warning: the ciphertext is committed to the timeline, which is public — anyone who clones your timeline can read the encrypted message. The confidentiality of a message therefore rests entirely on the strength of the encryption, so choose a strong recipient key. Both post-quantum PQP (ML-DSA / ML-KEM) and classic OpenPGP (via gpg) encryption are available; post-quantum is preferred.

🛡️ Sign and verify posts

Sign any post with the keypair of the active repository (see 🔐 Manage your keys). Signatures are embedded in the commit as a git gpgsig header — the same place git stores PGP/SSH signatures — so signed commits remain fully inspectable with git cat-file and friends.

$ tl post --sign "hello, quantum world"
posted (signed): hello, quantum world

$ tl verify 1
verified: post `1` has a valid signature

The signing key is discovered automatically from the repository's private keyring:

  • a single key is used without any extra flags;
  • several keys require --signer <KEY> (or the [sign] key config value), which must be one of the available key ids;
  • the tool is pqp when only post-quantum keys exist and gpg when only OpenPGP keys exist; with keys of both types --sign-tool pqp|gpg (or the [sign] tool config value) is required.
$ tl post --sign --signer a1b2c3d4e5f6a7b8 "pick a specific key"
$ tl post --sign --sign-tool gpg --signer F0F1... "openpgp signed"

The pqp binary path is set with [sign] program (default pqp) and the gpg binary path with [sign] gpg_program (default gpg):

[sign]
program = "pqp"
gpg_program = "gpg"
tool = "pqp"

tl verify <ID> reconstructs the exact commit payload the signature covers and checks it with pqp verify (or gpg --verify for OpenPGP signatures), so tampering is detected. If the author's public key is not available locally, tl looks for a PQPKEY (or PGPKEY for OpenPGP signatures) file in the root of the author's timeline, imports it into a dedicated keyring under the tl data directory, and verifies against it — your own pqp/gpg keyrings are never touched.

🔧 Raw git access

Run any git command inside the active repository's timeline:

$ tl git log --oneline
2f34bb8 hello git

The output and exit code of git are passed through unchanged, so tl git push and friends work exactly like running git by hand — handy for inspecting or repairing the underlying repository.

♻️ Reset your timeline

$ tl reset
timeline `default` reset

Runs git reset --hard to discard any dirty files and reset the working tree and index to the last commit. Handy after a follow leaves the timeline in an inconsistent state.

🔍 Search your timeline

Find any post whose text matches a pattern, across your own timeline, every user you follow, and the direct-message branches. The pattern is a glob by default (* matches any run of characters, ? a single character), matching any substring:

$ tl search "hello*"
3.  2026-08-01 15:25:30  @alice  💬  hello world 🌍
17. 2026-08-05 13:13:28  @test1    ✉  hola desde cache

Pass --regex to use a regular expression instead:

$ tl search --regex "h.llo w.rld"

Results are printed in the same table as tl list, so you can feed a result's id to tl show, tl reply or tl repost. Encrypted messages are searched by their plaintext — decrypted or read back from the cache/messages/ cache — never by the ciphertext committed to the branch. A message whose plaintext is not available can't match.

🔄 Sync your timeline

$ tl sync
timeline is up to date

sync pulls new commits from your own pull URL into your local timeline, runs git submodule update --init --remote to fetch the newest messages from every user you follow, and finally exchanges direct messages: it fetches the branch named with your own SHA3 id from each followed timeline (picking up the messages addressed to you) and pushes the branch named with each followed user's SHA3 id (delivering your replies to them). Run it anytime you want to catch up.

ℹ️ Inspect a timeline

$ tl info
repo:       main
path:       /home/alice/.local/share/tl/repos/main/timeline
pull url:   https://example.com/alice/timeline.git
push url:   git@example.com:alice/timeline.git
id:         1267de09c2d2285216ba2ab9621b7bcbbe97924cff29d496ec6ec3e4271e8a9c
messages:   12
follows:    3

Shows everything about a repository's timeline: its name and location, pull and push URLs, your SHA3 user id, message count, and how many users you follow.

🔎 Show a single post

Drill into any message shown by tl list, using its sequential id:

$ tl show 17
id:               17
kind:             message
source:           test1
date:             2026-08-05 13:13:28
author:           b50347c8a99bd8ea57618a8cc94e783a2a5b0bddcf64798fb03332b8d2f86db2
account:          b50347c8a99bd8ea57618a8cc94e783a2a5b0bddcf64798fb03332b8d2f86db2
repo:             file:///tmp/test1
commit:           78e26a748e5ac3de430016f45a81f3ca64547bc5
message id:       b50347c8...:78e26a74...
to:               575b65bf2b10cc25b1339661252bd16aa0c3595cd7fcdb55aece66992ff2e6f8
payload:          TUwtS0VNK1hDaGE...
plaintext:        hola desde cache
plaintext source: cache
signed:           no
verified:         -
message:
  hola desde cache

show prints the message content plus its metadata — kind, source timeline, date, author, account, repository, commit hash, and unique message id — plus kind-specific details (the followed URL of a follow, the parent of a reply/repost, the recipient of a message, the published key of a key). A direct message also shows its encrypted payload, its plaintext, and where the plaintext came from (cache for sent messages, decrypt for received ones). It also runs the same signature check as tl verify, reports it in signed/verified, and prints the raw signature payload of signed messages.

📋 List your timeline

$ tl list
1.  2026-08-01 15:10:00  alice  ✚  bob (https://example.com/bob/timeline.git)
2.  2026-08-01 15:10:00  alice  💬  first post!
3.  2026-08-01 15:25:30  alice  💬  hello world 🌍
4.  2026-08-01 15:20:00  bob    ⤷    nice one
5.  2026-08-02 09:00:00  alice  🔁  bob's first post

Every column is padded to the width of its widest value, so the fields line up vertically. The kind is shown as a single Unicode character: 💬 for posts, for follows, for replies, 🔁 for reposts, for direct messages and 🔑 for published keys. A follow shows the followed user's alias (or, without one, their short SHA3 id) together with their repository URL — never the literal word follow. Override the kind characters with --kind-post-char, --kind-follow-char, --kind-reply-char, --kind-repost-char, --kind-message-char and --kind-key-char (or the matching [list] config values), or show the kind name with --fields id,date,user,kind,message.

Signed posts are flagged and distinguish a valid signature from an invalid or unverifiable one: a verified signature shows (configurable with --kind-signed-char, color with --color-signed-char) followed by the kind character, e.g. ✹💬, and the kind name becomes post+signed, follow+signed, reply+signed, repost+signed, message+signed or key+signed. A signature that is invalid or cannot be verified shows (configurable with --kind-invalid-signed-char) followed by the kind character, e.g. ✗💬, and the kind name becomes post+signed+invalid (and so on). Validity is checked like tl verify, importing the author's key when needed. Run tl verify <ID> for details.

New posts are separated from the ones you've already seen: tl list remembers what it has printed, per repository, in a seen.txt state file under the data directory (<data>/state/seen-<repo>.txt, one message id per line) — so tl list --repo <name> keeps each repository's state separate. Posts that were never printed before show up below a horizontal line; in the default oldest-first order the line sits before the new posts, and with --reverse after them. The very first run marks everything as seen and draws no line. Disable it with --no-new-posts-line or [list] new_posts_line = false.

$ tl list
1.  2026-08-05 09:00:00  alice  💬  some older post
──────────────────────────────
2.  2026-08-05 18:54:11  alice  💬  brand new post

Every message carries a type, inferred from the commit that carries it: a follow <url> commit is a follow, a reply <id> <text> commit is a reply, a repost <id> <text> commit is a repost, a message <id> <payload> commit is a message, a key <key-id> commit is a key, everything else is a post.

Direct messages show up in the list too: the messages you sent live on the recipient's branch of your own timeline, and the messages you received live on the branch named with your id inside the sender's timeline. Received messages are decrypted for display when your private key is available in keyring/private/; sent messages show your own words read back from the cache/messages/ plaintext cache. A message that can't be shown (an old sent message whose plaintext was never cached, or a received message you have no key for) is rendered as (encrypted message) — the ciphertext is never displayed.

Every post is prefixed with a sequential id (1-based, oldest first over the whole timeline by default). Ids stay stable no matter the --limit or --reverse flags, so you can use them later to reply to or expand a specific post. Pass --reverse to print the newest posts first.

Replies show only their text: the reply <sha3>:<commit> link that identifies the parent is hidden from the output and used only to build the thread.

Replies are threaded: a reply is shown directly below the message it answers, indented by two spaces per nesting level (tune it with --reply-indent <N>). A reply whose original message is not present in any followed or local timeline is hidden by default; pass --show-orphans to see it.

By default post dates are rendered as YYYY-mm-dd HH:MM:ss. Pass a strftime-style format to change it:

$ tl list --date-format "%Y/%m/%d"

Options:

Option Description
--limit <N> Show at most N posts.
--no-follow Exclude the followed timelines and the local "follow" commits so only your own posts and replies are listed.
--reverse Print newest posts first (the default is oldest first).
--date-format <FMT> strftime-style format for post dates. Overrides the config value.
--show-orphans Keep replies whose original message is missing.
--format <FMT> Output format: table (default) or json.
--fields <FIELDS> Comma-separated columns for the table output.
--kind-post-char <C> Character used to render posts in the table.
--kind-follow-char <C> Character used to render follows in the table.
--kind-reply-char <C> Character used to render replies in the table.
--kind-repost-char <C> Character used to render reposts in the table.
--kind-message-char <C> Character used to render direct messages in the table.
--kind-key-char <C> Character used to render published-key commits in the table.
--kind-signed-char <C> Character used to render signed messages in the table.
--kind-invalid-signed-char <C> Character used to render signed messages whose signature is invalid or could not be verified.
--user-char <C> Character prefixed to the user name/id in the user column (default @).
--color-<FIELD> <SPEC> Color of a table column, e.g. --color-user red,bold.
--color-signed-char <SPEC> Color of the signed kind character.
--color-message-url <SPEC> Color used to highlight URLs in the message column (default bright_blue,underline).
--regex-message-url <RE> Regex used to match URLs in the message column (default (https?|file)://[^\s]*[^\s.,;!?()]).
--color-mention <SPEC> Color used to highlight user mentions in the message column (default bright_magenta).
--regex-mention <RE> Regex used to match user mentions in the message column (default @\w+); matches are rewritten to the followed user's alias when known.
--reply-indent <N> Spaces to indent each reply nesting level (default 2).
--no-new-posts-line Do not draw the line separating posts already printed by tl list from new ones, and do not track what has been printed. Overrides the config value.

Table colors are only emitted when stdout is a terminal. Override with --force-color (always) or --no-color (never):

$ tl list --color-user bright_blue --color-kind_char "196,bold"
$ tl list --color-message-url "yellow,underline" --regex-message-url "https?://[^\s]+"

The color spec is foreground,background,modifier; colors may be named or a 256-color index, and modifiers include bold, dim, italic, underline, blink, reverse, hidden and strikethrough. Per-field colors can be stored in the config under [list] colors.

Ask for the timeline as JSON for scripting:

$ tl list --format json
[{"id":1,"source":"alice","kind":"post","date":"2026-08-01 15:10:00","message":"first post!","message_id":"1267de09...:2f34bb8"}]

Each JSON post carries id, source, kind, date, message, message_id, and (for replies) parent_id.

Pick the columns you want in the table:

$ tl list --fields id,kind,message
1.  follow  bob (https://example.com/bob/timeline.git)
2.  post    first post!
3.  post    hello world 🌍
4.  reply     nice one

The reply row is indented (depth × --reply-indent) under its parent.

Available fields: id, date, user, kind_char, message (the default set), plus kind (the kind name), account (SHA3 user id), commit (commit hash), repo (pull URL), message_id, and parent (the id of the post a reply answers).

🌐 Global options

Option Description
--config <PATH> Override the configuration file path.
--data <PATH> Override the data root directory.
--repo <NAME> Repository to operate on (defaults to the first created).
--no-color Never emit ANSI colors, even on a terminal.
--force-color Always emit ANSI colors, even when piped.
--help Print help information.
--version Print version information.

🧬 Identity

A user id is computed as:

user_id(url) = hex( SHA3-256( url ) )

It is deterministic and collision-resistant, so the same URL always resolves to the same 64-character lowercase hex id — on any machine, forever.

📜 Configuration

The config file ($XDG_CONFIG_HOME/tl/tl.toml) holds your repositories, each with its own followed users and aliases:

default_repo = "main"

[repo.main]
pull_url = "https://example.com/alice/timeline.git"
push_url = "git@example.com:alice/timeline.git"

[repo.main.following]
"<sha3-of-url>" = "https://example.com/bob/timeline.git"

[repo.main.aliases]
"<sha3-of-url>" = "bob"

[repo.work]
pull_url = "https://example.com/alice/work.git"
push_url = "git@example.com:alice/work.git"

[list]
date_format = "%Y-%m-%d %H:%M:%S"
kind_repost_char = "🔁"
kind_message_char = ""
kind_key_char = "🔑"

[repost]
sign = false

See config/tl.toml.sample for a commented example.

Every command-line option is also available as a config-file key, with the precedence CLI flag > config value > built-in default. The full set of sections and keys:

Section Keys
top-level default_repo, data_dir
[repo.<name>] pull_url, push_url, following (sha3 id → URL), aliases (sha3 id → display alias)
[create] pull_url, push_url
[list] limit, no_follow, reverse, format, fields, date_format, show_orphans, kind_post_char, kind_follow_char, kind_reply_char, kind_repost_char, kind_message_char, kind_key_char, kind_signed_char, kind_invalid_signed_char, user_char, color, colors, color_signed_char, color_message_url, regex_message_url, color_mention, regex_mention, reply_indent
[post] sign, regex_mention
[reply] sign
[repost] sign
[search] regex
[sign] program, key, gpg_program, tool

data_dir (the full data root, equivalent to --data) is read first so the rest of the configuration can resolve relative to it.

🛠️ Building

Requires Rust 2021 edition (1.70+) and git on the PATH.

$ make            # build the release binary
$ make debug      # build the debug binary (target/debug/tl)
$ make test       # run the full test suite
$ make lint       # clippy with -D warnings + fmt check
$ make doc        # build the API documentation
$ make install    # install to /usr/local

Or with cargo directly:

$ cargo build --release
$ cargo test

📦 Releases & docs

  • Man page: man/tl.1 (make man to regenerate)
  • Shell completions: completions/tl.{bash,zsh,fish} (make completions)
  • API docs: cargo doc --no-deps --open

📋 Testing

The test suite never touches a real network. Unit tests run against an in-memory git mock, and integration tests exercise the real git binary against local repositories in temporary directories.

🔑 License

MIT — see COPYING for the full license text.

About

tl — a social network built on top of git

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages