Metadata-Version: 2.4
Name: n-commit
Version: 0.4.4
Summary: Git-shaped sync between Notion database views and folders of markdown files
Project-URL: Homepage, https://github.com/dhuynh95/n-commit
Project-URL: Repository, https://github.com/dhuynh95/n-commit
Project-URL: Issues, https://github.com/dhuynh95/n-commit/issues
Author-email: Daniel Huynh <daniel.quoc.huynh@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cli,markdown,mcp,notion,sync
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Version Control
Classifier: Topic :: Utilities
Requires-Python: >=3.11
Requires-Dist: mcp
Requires-Dist: pydantic
Requires-Dist: pyyaml
Requires-Dist: tomli-w
Description-Content-Type: text/markdown

# 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

```bash
pip install n-commit
```

First call opens a browser for OAuth. Tokens cache at `~/.config/n-commit/tokens/`.

## Onboarding

```bash
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:

```toml
[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>` (will `DriftError` if remote also moved → `--force`)
  - take remote: `restore <file>` then re-pull
  - both diverged: open file, mirror at `.notion/mirror/<rel>`, hand-merge, `push`
- `push` → `DriftError` (mirror diverged from current remote):
  - keep local: `pull --force`, re-merge, `push`. Or `push --force`.
  - take remote: `restore <file>`
- `status --remote` surfaces 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_row` is the single source of truth for row bytes.** Never mix `list_rows` props 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 via `ConflictError`.
- **Canonicalization for hashing.** `normalize_body` strips 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_id` is always 32-char undashed hex.** Normalize at boundaries via `url_to_id`.
- **Comments don't bump `Last edited time`.** Use `pull <file>` (always fetches) when you want comments.
- **`query-database-view` caps at 100 rows.** Larger views truncate silently — `pull <file>` is the escape hatch for the tail.
- **Empty `list_rows` is a no-op.** Not "everything was deleted".
- **OAuth refresh tokens are single-use.** `FileTokenStorage` uses `fcntl.flock` so concurrent sessions don't race.
- **`create_row` validates the parent.** Rejected property bags silently produce orphan pages on Notion's side; `create_row` re-fetches and verifies the parent matches.

## Practices

- `frontmatter.py` and `manifest.py` are pure. No IO outside their declared file.
- `core.py`'s primitives take `view_url` as an argument; only the CLI reads the manifest.
- No imports inside functions. Module top only.
- Explicit over compact. Readable loops beat dense comprehensions.
