n-commit
Git-shaped sync between Notion database views and local folders of markdown files. Notion is the source of truth; you decide when to pull and push.
Install
pip install n-commit
First call opens a browser for OAuth. Tokens cache at ~/.config/n-commit/tokens/.
Onboarding
mkdir my-notion && cd my-notion
# one-shot: folder name is derived from the Notion title
n-commit add "https://www.notion.so/…?v=…"
n-commit add "https://www.notion.so/…?v=…" --as crm/people # explicit folder
# batch: paste URLs in a loop (blank line or Ctrl-C ends)
n-commit add -i
# the first `add` creates n-commit.toml + .gitignore
To use the same workspace on another machine: copy n-commit.toml to a folder and run n-commit pull. (Git, scp, Dropbox — anything that moves a 1KB text file.)
Mental model
A workspace is a directory tree containing one n-commit.toml manifest at its root and one subfolder per binding. Each binding mirrors one Notion database view.
~/my-notion/
├── n-commit.toml ← the manifest (committed)
├── .gitignore ← ignores **/.notion/
├── interviews/
│ ├── _FIELDS.md ← cheat sheet, regenerated on bind
│ ├── jane.md ← row ↔ file
│ └── .notion/ ← cache (gitignored)
│ ├── schema.json
│ ├── fingerprints.json
│ └── mirror/jane.md ← last-pulled bytes
└── crm/people/
├── _FIELDS.md
├── alice.md
└── .notion/
Manifest:
[folders."interviews"]
view = "https://www.notion.so/…?v=…"
[folders."crm/people"]
view = "https://www.notion.so/…?v=…"
Workspace is found by walking up from cwd, like git finds .git/. One file describes every binding.
Verbs
add, remove, status, pull, push, restore. Run n-commit <verb> --help for the per-verb reference. API-call cost per verb:
| Verb | Reads remote | Writes remote | Writes local | Default API calls |
|---|---|---|---|---|
add |
yes (one-time) | no | yes | 1 list_rows + 1 fetch_schema (+ N fetch_row if --init=push) |
status |
only --remote |
no | no | 0 (or 1 list_rows per binding) |
pull |
yes | no | yes (clean only) | 1 list_rows per binding + N fetch_row (cache misses only) |
push |
yes (drift check) | yes | yes (echo) | 3/file (drift+update+echo); 2 with --force |
restore |
no | no | yes | 0 |
Conflict / drift playbook
pull→ConflictError(dirty file + remote also changed):- keep local:
push <file>(willDriftErrorif remote also moved →--force) - take remote:
restore <file>then re-pull - both diverged: open file, mirror at
.notion/mirror/<rel>, hand-merge,push
- keep local:
push→DriftError(mirror diverged from current remote):- keep local:
pull --force, re-merge,push. Orpush --force. - take remote:
restore <file>
- keep local:
status --remotesurfaces conflicts early — cheapest place to catch them.
Layout
| File | Role |
|---|---|
n_commit/core.py |
The five primitives + CLI entrypoint. All filesystem IO. |
n_commit/manifest.py |
n-commit.toml read/write/find. Pure, no Notion deps. |
n_commit/frontmatter.py |
Pure transforms: envelope parse, props ↔ YAML, body normalize, row fingerprint, slug. |
n_commit/client.py |
Typed Notion MCP wrapper. |
n_commit/auth.py |
Generic MCP OAuth (PKCE) with fcntl.flock to serialize refresh-token rotation. |
n_commit/mcp_client.py |
Generic MCP transport + dynamic tool proxy. |
Invariants
fetch_rowis the single source of truth for row bytes. Never mixlist_rowsprops with envelope-fetch props in the same path — they disagree on edge keys.- Mirror is always updated on pull, even on conflict. Working files are only overwritten if clean (or
--force). Conflicts surface loudly viaConflictError. - Canonicalization for hashing.
normalize_bodystrips presigned-S3 query strings so attachment URLs don't cause drift. CRLF → LF. Trailing whitespace stripped. Triple-newlines collapsed. - Read-only types are stripped on push:
created_time,last_edited_time,rollup,formula. notion_idis always 32-char undashed hex. Normalize at boundaries viaurl_to_id.- Comments don't bump
Last edited time. Usepull <file>(always fetches) when you want comments. query-database-viewcaps at 100 rows. Larger views truncate silently —pull <file>is the escape hatch for the tail.- Empty
list_rowsis a no-op. Not "everything was deleted". - OAuth refresh tokens are single-use.
FileTokenStorageusesfcntl.flockso concurrent sessions don't race. create_rowvalidates the parent. Rejected property bags silently produce orphan pages on Notion's side;create_rowre-fetches and verifies the parent matches.
Practices
frontmatter.pyandmanifest.pyare pure. No IO outside their declared file.core.py's primitives takeview_urlas an argument; only the CLI reads the manifest.- No imports inside functions. Module top only.
- Explicit over compact. Readable loops beat dense comprehensions.
Metadata
Release files for n-commit 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| n_commit-0.5.1.tar.gz | 34.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| n_commit-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 74.2 kB
Release files / n_commit-0.5.1.tar.gz
| Download URL | n_commit-0.5.1.tar.gz |
|---|---|
| Size | 34.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1bd081d14c01d0573da50db08804d76f63b16eaa1c0186218f2c040a02f29179
|
|
BLAKE2b-256 checksum How to use checksums |
6f207263ae422b8be974349c44780e0be20a897994e115566e985741366ff879
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.3
|
Release files / n_commit-0.5.1-py3-none-any.whl
| Download URL | n_commit-0.5.1-py3-none-any.whl |
|---|---|
| Size | 39.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
acacd87c327978851dc206ed9dd3236085166c54eafd55fad6b5738a45adb75f
|
|
BLAKE2b-256 checksum How to use checksums |
c0bda3739c254495020e04306b495bbecde051c87fd35ff0f41f0ac6c56821ab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.3
|