Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9321590f5 | ||
|
|
eb70ca08ad | ||
|
|
ddb829d701 | ||
|
|
9e3201a296 |
@@ -1,4 +1,4 @@
|
|||||||
# granthi-sync v1
|
# granthi-sync v1.2
|
||||||
|
|
||||||
The signup → download → link-folders → cloud product spine for the Granthi
|
The signup → download → link-folders → cloud product spine for the Granthi
|
||||||
forge, tested against the BETA forge (granthi-beta.shre.ai). Python 3 stdlib +
|
forge, tested against the BETA forge (granthi-beta.shre.ai). Python 3 stdlib +
|
||||||
@@ -50,10 +50,17 @@ cd granthi-sync
|
|||||||
# 3b. ...or push a local folder up. It becomes a private repo.
|
# 3b. ...or push a local folder up. It becomes a private repo.
|
||||||
./bin/granthi-sync add ~/work/notes
|
./bin/granthi-sync add ~/work/notes
|
||||||
|
|
||||||
|
# 3c. ...or pull down everything this account is allowed to see.
|
||||||
|
./bin/granthi-sync get --all --into ~/granthi
|
||||||
|
|
||||||
# 4. Keep everything synced. Autocommits, ff-pulls, pushes; skips anything
|
# 4. Keep everything synced. Autocommits, ff-pulls, pushes; skips anything
|
||||||
# that has diverged rather than merging or forcing.
|
# that has diverged rather than merging or forcing.
|
||||||
./bin/granthi-sync watch # --once for a single pass
|
./bin/granthi-sync watch # --once for a single pass
|
||||||
./bin/granthi-sync status # what is linked, last sync, divergence
|
./bin/granthi-sync status # what is linked, mode, last sync
|
||||||
|
|
||||||
|
# 5. Go back to how a folder looked at some point in time.
|
||||||
|
./bin/granthi-sync snapshots ~/work/notes
|
||||||
|
./bin/granthi-sync restore ~/work/notes --at 20260823T142530Z
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `watch` as a background daemon on macOS with
|
Run `watch` as a background daemon on macOS with
|
||||||
@@ -67,6 +74,104 @@ no account, no token, no partial state. Just run `link` again.
|
|||||||
to merge; when a folder shows `DIVERGED` in `status`, resolve it in git or on
|
to merge; when a folder shows `DIVERGED` in `status`, resolve it in git or on
|
||||||
the forge web UI. The client will never force or auto-merge your work.
|
the forge web UI. The client will never force or auto-merge your work.
|
||||||
|
|
||||||
|
## Two modes, because two very different folders ask for this
|
||||||
|
|
||||||
|
A folder people sync is either *their documents* or *their git project*, and
|
||||||
|
the correct behaviour is opposite in each case. Each linked folder therefore
|
||||||
|
carries a `mode`.
|
||||||
|
|
||||||
|
| | `mirror` | `snapshot` |
|
||||||
|
|---|---|---|
|
||||||
|
| chosen for | a plain folder `add` turned into a repo | a folder that was already a git repo, and anything `get` clones |
|
||||||
|
| commits on your behalf | yes, `sync: <ISO ts>` | **never** |
|
||||||
|
| where work lands | the branch | `refs/granthi-backup/<device>/<ts>` |
|
||||||
|
| a restore point is | every commit | every snapshot |
|
||||||
|
|
||||||
|
`snapshot` mode is what "the work may not be committed, but it is still
|
||||||
|
backed up" means in git terms. Each pass loads a scratch index from HEAD,
|
||||||
|
stages the working tree into *that* index, writes a tree, and commits it with
|
||||||
|
`commit-tree`. HEAD, your index, your stash and every file on disk are
|
||||||
|
untouched — you can be mid-rebase with a dirty tree and the backup still
|
||||||
|
records exactly what is on the disk right now. The user's history stays the
|
||||||
|
user's.
|
||||||
|
|
||||||
|
Why a custom ref namespace: verified on the beta forge (Gitea 1.27.2) that
|
||||||
|
`refs/granthi-backup/...` is accepted, is readable through `ls-remote`, and
|
||||||
|
does **not** appear in the branch list. Under `refs/heads` a machine taking a
|
||||||
|
backup every 30 seconds would bury the branches a person actually made.
|
||||||
|
|
||||||
|
Snapshots are parented on HEAD and deliberately **not** chained to the
|
||||||
|
previous snapshot: chaining would keep every old snapshot reachable from the
|
||||||
|
newest, so pruning a ref would free nothing and retention would be
|
||||||
|
decorative.
|
||||||
|
|
||||||
|
**Retention** (or 30-second backups become a disk leak nobody can navigate):
|
||||||
|
everything is kept for 24 h, then thinned to hourly for 7 days, then daily.
|
||||||
|
Pruning runs at most hourly, per device, and only over that device's own
|
||||||
|
refs. A ref whose timestamp this version cannot parse is **kept** — deleting
|
||||||
|
the unrecognised is how a backup system loses the one thing someone needed.
|
||||||
|
|
||||||
|
**Restore never writes over the working tree.** `restore` materialises a
|
||||||
|
restore point into a *new* directory and refuses a non-empty destination.
|
||||||
|
Someone restoring a backup is already having a bad day; overwriting the files
|
||||||
|
they still have would make the recovery tool the second disaster.
|
||||||
|
|
||||||
|
## The credential helper must be the ONLY helper (found by live QA)
|
||||||
|
|
||||||
|
`credential.helper` is a list that accumulates across system, global and repo
|
||||||
|
config, and git asks every helper in it. A stock mac already has two —
|
||||||
|
`osxkeychain` from Xcode's gitconfig, and `store` from many people's
|
||||||
|
`~/.gitconfig` — and they lose in both directions:
|
||||||
|
|
||||||
|
* **reading:** a stale entry for the forge host answers before our helper, so
|
||||||
|
pushes fail `remote: Failed to authenticate user` long after the token was
|
||||||
|
rotated, and nothing in this tool's config explains why. This is exactly
|
||||||
|
how the first live-QA run failed;
|
||||||
|
* **writing:** git calls `approve` on every helper after a successful auth,
|
||||||
|
so `store` copies the forge token into `~/.git-credentials` **in
|
||||||
|
plaintext**. Keeping the token in a 0600 file and out of remote URLs buys
|
||||||
|
nothing if git then hands it to a plaintext store.
|
||||||
|
|
||||||
|
So `install_credential_helper` (and the `git clone` in `get`) sets an **empty**
|
||||||
|
`credential.helper` first, which resets the inherited list, then adds ours.
|
||||||
|
Exactly one helper serves this repo.
|
||||||
|
|
||||||
|
Corollary worth remembering: a token embedded in a remote URL gets saved by
|
||||||
|
`store` on first use. During QA a verification clone with a URL-embedded
|
||||||
|
token re-created the very entry that had just been cleaned out. That is the
|
||||||
|
whole reason this client passes tokens through a helper and never a URL.
|
||||||
|
|
||||||
|
## Device identity
|
||||||
|
|
||||||
|
`link` mints a uuid on first run and persists it in `~/.granthi-sync/config.json`
|
||||||
|
as `device_id`, and sends it to `/v1/link`. Hostnames are neither stable
|
||||||
|
(people rename laptops) nor unique (every new mac is "Mac mini"), so a
|
||||||
|
hostname cannot key a backup ref or a device registry — two machines would
|
||||||
|
overwrite each other's snapshots. The service-side device registry is the
|
||||||
|
next phase; the client leads so the id already exists when it lands.
|
||||||
|
|
||||||
|
## What "add the computer to the network" means — and does not
|
||||||
|
|
||||||
|
The onboarding shape is: download → login → **the device is federated to the
|
||||||
|
account** → the device can reach its repos.
|
||||||
|
|
||||||
|
The middle step is a *device registration*, not a network membership. Those
|
||||||
|
sound like one step and must not be built as one: this estate's tailnet is a
|
||||||
|
single flat private network carrying the granthi VPS, aros-vps, the Shadow
|
||||||
|
box and the Mac. Putting a customer's laptop on it to let them sync a folder
|
||||||
|
would hand that laptop L3 reach to every piece of infrastructure we run.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
* **Our own machines** may join the tailnet — that is an operator action with
|
||||||
|
an operator's judgement behind it.
|
||||||
|
* **Customer devices never do.** Their transport is public HTTPS to
|
||||||
|
granthi-link and the forge through cloudflared. That is the same exposure
|
||||||
|
step already in the promotion window below, and it is what makes a genuinely
|
||||||
|
new computer able to onboard itself at all — today `link` only works from
|
||||||
|
inside the tailnet, which means "you can't set up a new computer without an
|
||||||
|
operator first" is the honest status.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### `server/granthi_link.py` — provisioning service (granthi VPS)
|
### `server/granthi_link.py` — provisioning service (granthi VPS)
|
||||||
@@ -211,13 +316,32 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
|
|||||||
(`authorization_pending`/`slow_down` handled), then calls `/v1/link`.
|
(`authorization_pending`/`slow_down` handled), then calls `/v1/link`.
|
||||||
`--token` skips the device flow with a ready Zitadel token (headless/dev).
|
`--token` skips the device flow with a ready Zitadel token (headless/dev).
|
||||||
Result stored in `~/.granthi-sync/config.json` (0600).
|
Result stored in `~/.granthi-sync/config.json` (0600).
|
||||||
* `list` — every repo the linked token can see, with the local folder each
|
* `list [pattern]` — every repo the linked token can see, with the local
|
||||||
is already synced to. Reads `GET /api/v1/user/repos` on the forge
|
folder each is already synced to. `pattern` narrows the table by name
|
||||||
|
(substring, or a glob like `work-*`), case-insensitively, against both
|
||||||
|
`owner/name` and the bare name. Filtering is display-only: the set already
|
||||||
|
came from the forge under this account's token. Reads
|
||||||
|
`GET /api/v1/user/repos` on the forge
|
||||||
**directly** with the scoped user token — no granthi-link round-trip, so
|
**directly** with the scoped user token — no granthi-link round-trip, so
|
||||||
the read path needs no service change. Pagination is followed to a short
|
the read path needs no service change. Pagination is followed to a short
|
||||||
page; if the `FORGE_MAX_PAGES` guard trips, the output says the list is
|
page; if the `FORGE_MAX_PAGES` guard trips, the output says the list is
|
||||||
incomplete rather than letting a bounded page read as the whole set.
|
incomplete rather than letting a bounded page read as the whole set.
|
||||||
* `get <repo|owner/repo> [--into DIR]` — the download half of `add`. Clones
|
* `get --all [--into DIR] [--mode M]` — clone every repo this account can
|
||||||
|
see, skipping the ones already linked here. **"Only the repos they are
|
||||||
|
granted" needs no client-side permission logic**: `/api/v1/user/repos` is
|
||||||
|
evaluated by the forge against this account's own scoped token, so the
|
||||||
|
list *is* the grant. A client-side filter would be a second opinion about
|
||||||
|
someone else's authorisation. One repo failing does not abandon the rest,
|
||||||
|
and a truncated listing is reported loudly — `--all` must never quietly
|
||||||
|
mean "the first 2000". `alice/notes` and `bob/notes` both want
|
||||||
|
`<base>/notes`; the second is cloned to `<base>/bob-notes` and the clash is
|
||||||
|
logged, because reporting it as "already present" would leave the user
|
||||||
|
believing they had pulled both.
|
||||||
|
* `get <repo|owner/repo> [--into DIR] [--mode M]` — the download half of
|
||||||
|
`add`. Defaults to `snapshot` mode unless the repo carries a
|
||||||
|
`.granthi-sync.json` marker saying otherwise, so a plain synced folder
|
||||||
|
behaves the same on the second machine while someone's real project is
|
||||||
|
never autocommitted onto. Clones
|
||||||
with `--origin granthi` (the remote name `watch` looks for) and
|
with `--origin granthi` (the remote name `watch` looks for) and
|
||||||
`-c credential.helper=…` (the repo does not exist yet, so the helper
|
`-c credential.helper=…` (the repo does not exist yet, so the helper
|
||||||
cannot be installed first; git also persists it into the new config), then
|
cannot be installed first; git also persists it into the new config), then
|
||||||
@@ -225,22 +349,76 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
|
|||||||
so a cloned repo is picked up by `watch` immediately. Refuses a non-empty
|
so a cloned repo is picked up by `watch` immediately. Refuses a non-empty
|
||||||
destination. Branch is read with `symbolic-ref` (an empty repo has an
|
destination. Branch is read with `symbolic-ref` (an empty repo has an
|
||||||
unborn HEAD) and falls back to `main`.
|
unborn HEAD) and falls back to `main`.
|
||||||
* `add <folder> [--name N] [--private|--public]` — `git init -b main` if
|
* `add <folder> [--name N] [--private|--public] [--mode M] [--force]` —
|
||||||
needed, creates the cloud repo via `/v1/repos`, adds remote `granthi`,
|
`git init -b main` if needed, creates the cloud repo via `/v1/repos`, adds
|
||||||
initial commit + push. The token is delivered by a **git credential
|
remote `granthi`, initial commit + push. The token is delivered by a **git
|
||||||
helper** (the client's hidden `git-credential` subcommand reading the 0600
|
credential helper** (the client's hidden `git-credential` subcommand
|
||||||
config) — never embedded in the remote URL (estate rule).
|
reading the 0600 config) — never embedded in the remote URL (estate rule).
|
||||||
* `watch [--interval 30] [--once]` — per folder: autocommit
|
Pushes the folder's **current** branch, not a hardcoded `main`: an existing
|
||||||
(`sync: <ISO ts>`) → fetch → ff-pull if remote strictly ahead → push if
|
repo may sit on `master` or a feature branch, and publishing that work
|
||||||
local strictly ahead. **DIVERGED → log + record + SKIP. Never force, never
|
under the wrong name is not a cosmetic error.
|
||||||
merge** — the same policy as the mesh. SIGTERM-clean.
|
Two guards, because `add -A` takes whatever it is given: a starter
|
||||||
* `status` — table of linked folders, last sync, divergence flags.
|
`.gitignore` is seeded when the folder has none (an existing one is never
|
||||||
|
touched — it is the user's), and a folder over 20 000 files / 512 MB is
|
||||||
|
refused unless `--force`. The seeded ignore file covers `.env`, `*.key`,
|
||||||
|
`*.pem`, `id_rsa` and friends, and it governs snapshots too — the scratch
|
||||||
|
index honours `.gitignore` exactly as a normal commit does.
|
||||||
|
The size guard measures what git *would* sync, ignore rules included
|
||||||
|
(including the machine's global excludes), because its own advice is "add
|
||||||
|
a .gitignore for what should not sync" and advice that changes nothing is
|
||||||
|
worse than none. It asks git through a **throwaway git dir outside the
|
||||||
|
folder**, so a refused `add` leaves no `.git` behind in a directory the
|
||||||
|
user never agreed to turn into a repo.
|
||||||
|
* `watch [--interval 30] [--once]` — per folder, by mode. `mirror`:
|
||||||
|
autocommit (`sync: <ISO ts>`) → fetch → ff-pull if remote strictly ahead →
|
||||||
|
push if local strictly ahead. `snapshot`: fetch → push a snapshot of the
|
||||||
|
working tree to this device's backup ref → ff-pull only when the tree is
|
||||||
|
clean (local edits are already safe on the backup ref, so it reports and
|
||||||
|
leaves the tree alone rather than failing) → push the user's own commits
|
||||||
|
when they are strictly ahead. **DIVERGED → log + record + SKIP. Never
|
||||||
|
force, never merge** — the same policy as the mesh — **but the backup
|
||||||
|
still happens**, because divergence is when work is most at risk.
|
||||||
|
Retention pruning runs at most hourly. SIGTERM-clean.
|
||||||
|
* `snapshots <folder> [--limit 20]` — restore points, newest first, **across
|
||||||
|
every device**, with the device that took each one. Read from the
|
||||||
|
**remote**, not a local cache: the feature exists for the case where this
|
||||||
|
machine is gone.
|
||||||
|
|
||||||
|
The three scopes differ deliberately. Writing is device-scoped, so two
|
||||||
|
machines never overwrite each other. Pruning is device-scoped, so machine A
|
||||||
|
never applies its clock to machine B's refs. **Reading is not scoped** — a
|
||||||
|
replacement laptop has a new id, and scoping the read to it would print
|
||||||
|
"no restore points yet" while the backups sit on the forge. That defect
|
||||||
|
was live in the first draft and is now pinned by a test that restores a
|
||||||
|
dead machine's work from a fresh clone.
|
||||||
|
* `restore <folder> --at <ts|sha> [--into DIR]` — materialise one restore
|
||||||
|
point into a new directory; refuses a non-empty destination. Accepts what
|
||||||
|
`snapshots` printed in either mode, including a mirror-mode `%cI`
|
||||||
|
timestamp. Two commits inside the same second share that timestamp, so an
|
||||||
|
ambiguous `--at` is **refused with the candidate ids** rather than
|
||||||
|
resolved by guessing.
|
||||||
|
* `status` — table of linked folders, mode, last sync, divergence flags.
|
||||||
* Run as a daemon on macOS with `client/launchd/ai.granthi.sync.plist`
|
* Run as a daemon on macOS with `client/launchd/ai.granthi.sync.plist`
|
||||||
(edit the script path, then `launchctl bootstrap gui/$UID <plist>`).
|
(edit the script path, then `launchctl bootstrap gui/$UID <plist>`).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
* `python3 -m unittest discover -s tests` — 65 tests: autocommit/ff/diverged
|
* `python3 -m unittest discover -s tests` — 153 tests. The v1.2 additions
|
||||||
|
cover: a snapshot capturing uncommitted work while HEAD, the index and the
|
||||||
|
working tree stay byte-identical; snapshots landing outside `refs/heads`;
|
||||||
|
an unchanged tree not being re-pushed; a diverged folder still being backed
|
||||||
|
up; a dirty tree blocking the ff-pull but not the backup; retention keeping
|
||||||
|
everything recent, thinning to hourly then daily, and **keeping**
|
||||||
|
unparseable timestamps; prune deleting only the thinned refs; `.gitignore`
|
||||||
|
seeding never overwriting an existing one and keeping `.env` out of
|
||||||
|
snapshots; the folder-size guard being bounded rather than walking the
|
||||||
|
disk; mode detection; `list` filtering; `get --all` skipping what is
|
||||||
|
already present, defaulting to snapshot mode, and shouting about
|
||||||
|
truncation; `restore` writing a new folder, refusing a non-empty
|
||||||
|
destination, and leaving the working tree alone; and the credential helper
|
||||||
|
being the only one the repo consults, proven by driving
|
||||||
|
`git credential fill` against a deliberately poisoned outer helper.
|
||||||
|
* Earlier suite: autocommit/ff/diverged
|
||||||
logic against real temp git repos (including "diverged never touches the
|
logic against real temp git repos (including "diverged never touches the
|
||||||
remote"), config 0600 handling (including umask-proof creation and a
|
remote"), config 0600 handling (including umask-proof creation and a
|
||||||
no-chmod guard), credential-helper quoting/injection, mocked device-flow
|
no-chmod guard), credential-helper quoting/injection, mocked device-flow
|
||||||
@@ -267,6 +445,38 @@ the provisioning service creates their forge account + scoped token on the
|
|||||||
fly — the forge never sees a password and the user never sees the forge admin.
|
fly — the forge never sees a password and the user never sees the forge admin.
|
||||||
Every linked folder becomes a private repo under their account.
|
Every linked folder becomes a private repo under their account.
|
||||||
|
|
||||||
|
## Next phase — invites and per-repo access (designed, not built)
|
||||||
|
|
||||||
|
Today `/v1/link` creates an account and every folder becomes a private repo
|
||||||
|
under it. What is missing is the multi-person case: an existing account
|
||||||
|
inviting somebody, and that person's device waking up with access to *some*
|
||||||
|
repos and not others.
|
||||||
|
|
||||||
|
Shape this should take, so the next session does not re-litigate it:
|
||||||
|
|
||||||
|
* **Where grants live: granthi-link's own store, not Zitadel orgs.** The
|
||||||
|
estate's house pattern is app-side tenancy tables with the IdP only
|
||||||
|
providing identity (see the AROS `tenants` / `tenant_members` split).
|
||||||
|
Grants therefore sit beside `state.json`, and **Gitea is the enforcement
|
||||||
|
point** — a grant is materialised as a repo collaborator or an org team
|
||||||
|
membership, so the forge itself refuses unauthorised reads. Nothing in the
|
||||||
|
client decides access, which is why `get --all` needs no permission logic.
|
||||||
|
* **Token scope does not change.** `write:repository,write:user` stays; per
|
||||||
|
repo permission is collaborator/team state, not a token property.
|
||||||
|
* **`POST /v1/invite`** (account admin → new member): creates the shre-id
|
||||||
|
user (Zitadel admin PAT, on aros-vps at
|
||||||
|
`/opt/shre-id/deploy/secrets/shre_id_zitadel_pat`), records the intended
|
||||||
|
grants, and returns an invite the person redeems by running `link`. Until
|
||||||
|
they redeem it, nothing exists on the forge.
|
||||||
|
* **`POST /v1/grants`** (account admin): add/remove repo access for a member;
|
||||||
|
applies the change to Gitea and records it. Removing a grant must also
|
||||||
|
remove the collaborator — a grant store that drifts from the forge is
|
||||||
|
worse than no store.
|
||||||
|
* Both endpoints are account-admin-only and rate-limited like `/v1/link`.
|
||||||
|
* The grant store inherits the same fragility already noted for the rate
|
||||||
|
limiter: a flat JSON file behind an in-process lock, fine for one
|
||||||
|
`ThreadingHTTPServer` and **not** fine the day this runs multi-process.
|
||||||
|
|
||||||
## Promotion window (beta → prod)
|
## Promotion window (beta → prod)
|
||||||
|
|
||||||
1. **Expose :3042** behind cloudflared (granthi.shre.ai vhost or
|
1. **Expose :3042** behind cloudflared (granthi.shre.ai vhost or
|
||||||
|
|||||||
+805
-41
File diff suppressed because it is too large
Load Diff
+681
-9
@@ -10,6 +10,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -33,6 +34,17 @@ def run_git(cwd, *args):
|
|||||||
capture_output=True, text=True, env=GIT_ENV).stdout.strip()
|
capture_output=True, text=True, env=GIT_ENV).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_ns(**kw):
|
||||||
|
"""Namespace for cmd_get with the parser's defaults filled in, so a test
|
||||||
|
exercises the same shape argparse hands the command."""
|
||||||
|
kw.setdefault("all", False)
|
||||||
|
kw.setdefault("mode", None)
|
||||||
|
kw.setdefault("into", None)
|
||||||
|
kw.setdefault("repo", None)
|
||||||
|
return argparse.Namespace(**kw)
|
||||||
|
|
||||||
|
|
||||||
class GitScenarioBase(unittest.TestCase):
|
class GitScenarioBase(unittest.TestCase):
|
||||||
"""bare 'cloud' repo + two working clones to simulate device vs remote."""
|
"""bare 'cloud' repo + two working clones to simulate device vs remote."""
|
||||||
|
|
||||||
@@ -341,7 +353,7 @@ class TestGet(GitScenarioBase):
|
|||||||
|
|
||||||
def test_get_clones_registers_and_is_watchable(self):
|
def test_get_clones_registers_and_is_watchable(self):
|
||||||
dest = os.path.join(self.tmp, "pulled")
|
dest = os.path.join(self.tmp, "pulled")
|
||||||
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
|
client.cmd_get(get_ns(repo="cloud", into=dest))
|
||||||
|
|
||||||
# cloned content
|
# cloned content
|
||||||
self.assertTrue(os.path.exists(os.path.join(dest, "hello.txt")))
|
self.assertTrue(os.path.exists(os.path.join(dest, "hello.txt")))
|
||||||
@@ -366,7 +378,7 @@ class TestGet(GitScenarioBase):
|
|||||||
|
|
||||||
def test_get_accepts_owner_qualified_name(self):
|
def test_get_accepts_owner_qualified_name(self):
|
||||||
dest = os.path.join(self.tmp, "pulled2")
|
dest = os.path.join(self.tmp, "pulled2")
|
||||||
client.cmd_get(argparse.Namespace(repo="alice/cloud", into=dest))
|
client.cmd_get(get_ns(repo="alice/cloud", into=dest))
|
||||||
self.assertTrue(os.path.exists(os.path.join(dest, "hello.txt")))
|
self.assertTrue(os.path.exists(os.path.join(dest, "hello.txt")))
|
||||||
|
|
||||||
def test_get_refuses_non_empty_destination(self):
|
def test_get_refuses_non_empty_destination(self):
|
||||||
@@ -374,14 +386,14 @@ class TestGet(GitScenarioBase):
|
|||||||
os.makedirs(dest)
|
os.makedirs(dest)
|
||||||
self.write(dest, "mine.txt", "do not clobber")
|
self.write(dest, "mine.txt", "do not clobber")
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
|
client.cmd_get(get_ns(repo="cloud", into=dest))
|
||||||
self.assertEqual(open(os.path.join(dest, "mine.txt")).read(),
|
self.assertEqual(open(os.path.join(dest, "mine.txt")).read(),
|
||||||
"do not clobber")
|
"do not clobber")
|
||||||
|
|
||||||
def test_get_unlinked_exits_like_add(self):
|
def test_get_unlinked_exits_like_add(self):
|
||||||
with mock.patch.object(client, "load_config", lambda: {}):
|
with mock.patch.object(client, "load_config", lambda: {}):
|
||||||
with self.assertRaises(SystemExit) as cm:
|
with self.assertRaises(SystemExit) as cm:
|
||||||
client.cmd_get(argparse.Namespace(repo="cloud", into=None))
|
client.cmd_get(get_ns(repo="cloud", into=None))
|
||||||
self.assertIn("not linked", str(cm.exception))
|
self.assertIn("not linked", str(cm.exception))
|
||||||
|
|
||||||
def test_get_empty_repo_falls_back_to_main(self):
|
def test_get_empty_repo_falls_back_to_main(self):
|
||||||
@@ -389,7 +401,7 @@ class TestGet(GitScenarioBase):
|
|||||||
subprocess.run(["git", "init", "--bare", "-b", "main", empty],
|
subprocess.run(["git", "init", "--bare", "-b", "main", empty],
|
||||||
check=True, capture_output=True, env=GIT_ENV)
|
check=True, capture_output=True, env=GIT_ENV)
|
||||||
dest = os.path.join(self.tmp, "blank")
|
dest = os.path.join(self.tmp, "blank")
|
||||||
client.cmd_get(argparse.Namespace(repo="blank", into=dest))
|
client.cmd_get(get_ns(repo="blank", into=dest))
|
||||||
meta = client.load_config()["folders"][os.path.abspath(dest)]
|
meta = client.load_config()["folders"][os.path.abspath(dest)]
|
||||||
self.assertEqual(meta["branch"], "main")
|
self.assertEqual(meta["branch"], "main")
|
||||||
|
|
||||||
@@ -399,17 +411,17 @@ class TestGet(GitScenarioBase):
|
|||||||
"", "alice/"]:
|
"", "alice/"]:
|
||||||
with self.subTest(repo=bad):
|
with self.subTest(repo=bad):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
client.cmd_get(argparse.Namespace(repo=bad, into=None))
|
client.cmd_get(get_ns(repo=bad, into=None))
|
||||||
|
|
||||||
def test_get_records_full_name_so_list_matches_the_right_owner(self):
|
def test_get_records_full_name_so_list_matches_the_right_owner(self):
|
||||||
dest = os.path.join(self.tmp, "pulled4")
|
dest = os.path.join(self.tmp, "pulled4")
|
||||||
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
|
client.cmd_get(get_ns(repo="cloud", into=dest))
|
||||||
meta = client.load_config()["folders"][os.path.abspath(dest)]
|
meta = client.load_config()["folders"][os.path.abspath(dest)]
|
||||||
self.assertEqual(meta["full_name"], "alice/cloud")
|
self.assertEqual(meta["full_name"], "alice/cloud")
|
||||||
|
|
||||||
def test_list_does_not_mark_a_same_named_other_owner_repo_as_local(self):
|
def test_list_does_not_mark_a_same_named_other_owner_repo_as_local(self):
|
||||||
dest = os.path.join(self.tmp, "pulled5")
|
dest = os.path.join(self.tmp, "pulled5")
|
||||||
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
|
client.cmd_get(get_ns(repo="cloud", into=dest))
|
||||||
cfg = client.load_config()
|
cfg = client.load_config()
|
||||||
repos = [{"name": "cloud", "full_name": "alice/cloud", "private": True,
|
repos = [{"name": "cloud", "full_name": "alice/cloud", "private": True,
|
||||||
"updated_at": "2026-08-20T00:00:00Z"},
|
"updated_at": "2026-08-20T00:00:00Z"},
|
||||||
@@ -426,9 +438,669 @@ class TestGet(GitScenarioBase):
|
|||||||
|
|
||||||
def test_get_never_puts_token_in_remote_url(self):
|
def test_get_never_puts_token_in_remote_url(self):
|
||||||
dest = os.path.join(self.tmp, "pulled3")
|
dest = os.path.join(self.tmp, "pulled3")
|
||||||
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
|
client.cmd_get(get_ns(repo="cloud", into=dest))
|
||||||
self.assertNotIn("sekrit", run_git(dest, "remote", "get-url", "granthi"))
|
self.assertNotIn("sekrit", run_git(dest, "remote", "get-url", "granthi"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshots(GitScenarioBase):
|
||||||
|
"""The whole point of snapshot mode: work that was never committed still
|
||||||
|
leaves the machine, and the user's own history is not touched."""
|
||||||
|
|
||||||
|
DEV = "dev0123456789"
|
||||||
|
|
||||||
|
def test_snapshot_captures_uncommitted_work_without_moving_head(self):
|
||||||
|
self.write(self.local, "a.txt", "committed")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "real commit")
|
||||||
|
head_before = run_git(self.local, "rev-parse", "HEAD")
|
||||||
|
self.write(self.local, "a.txt", "UNCOMMITTED EDIT")
|
||||||
|
self.write(self.local, "new.txt", "never staged")
|
||||||
|
status_before = run_git(self.local, "status", "--porcelain")
|
||||||
|
# (run_git strips, so the leading space of ' M' is gone here)
|
||||||
|
self.assertEqual(status_before, "M a.txt\n?? new.txt")
|
||||||
|
|
||||||
|
commit, tree = client.build_snapshot(self.local)
|
||||||
|
|
||||||
|
self.assertEqual(run_git(self.local, "rev-parse", "HEAD"), head_before)
|
||||||
|
# the index is untouched: a.txt is still merely modified, not staged,
|
||||||
|
# and new.txt is still untracked. A snapshot that quietly staged the
|
||||||
|
# user's files would corrupt whatever they were in the middle of.
|
||||||
|
self.assertEqual(run_git(self.local, "status", "--porcelain"),
|
||||||
|
status_before)
|
||||||
|
# working tree still holds exactly what the user left there
|
||||||
|
with open(os.path.join(self.local, "a.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "UNCOMMITTED EDIT")
|
||||||
|
# ...and the snapshot commit holds it too
|
||||||
|
blob = run_git(self.local, "show", f"{commit}:a.txt")
|
||||||
|
self.assertEqual(blob, "UNCOMMITTED EDIT")
|
||||||
|
self.assertIn("new.txt", run_git(self.local, "ls-tree", "--name-only",
|
||||||
|
tree))
|
||||||
|
self.assertEqual(run_git(self.local, "log", "-1", "--format=%P",
|
||||||
|
commit), head_before)
|
||||||
|
|
||||||
|
def test_snapshot_is_none_when_nothing_is_uncommitted(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
self.assertIsNone(client.build_snapshot(self.local))
|
||||||
|
|
||||||
|
def test_push_snapshot_lands_in_the_backup_namespace(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
self.write(self.local, "a.txt", "work in progress")
|
||||||
|
|
||||||
|
ref = client.push_snapshot(self.local, self.DEV)
|
||||||
|
|
||||||
|
self.assertTrue(ref.startswith(f"refs/granthi-backup/{self.DEV}/"), ref)
|
||||||
|
refs = run_git(self.bare, "for-each-ref", "--format=%(refname)")
|
||||||
|
self.assertIn(ref, refs.splitlines())
|
||||||
|
# it is NOT a branch: the user's branch list stays theirs
|
||||||
|
self.assertNotIn("refs/heads/granthi-backup", refs)
|
||||||
|
|
||||||
|
def test_push_snapshot_skips_an_unchanged_tree(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
self.write(self.local, "a.txt", "work in progress")
|
||||||
|
self.assertIsNotNone(client.push_snapshot(self.local, self.DEV))
|
||||||
|
self.assertIsNone(client.push_snapshot(self.local, self.DEV))
|
||||||
|
|
||||||
|
def test_snapshot_mode_never_commits_for_the_user(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "mine")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
head_before = run_git(self.local, "rev-parse", "HEAD")
|
||||||
|
self.write(self.local, "a.txt", "dirty")
|
||||||
|
|
||||||
|
outcome, detail = client.sync_folder(self.local, mode="snapshot",
|
||||||
|
dev=self.DEV)
|
||||||
|
|
||||||
|
self.assertEqual(run_git(self.local, "rev-parse", "HEAD"), head_before)
|
||||||
|
self.assertIn("backed up", detail)
|
||||||
|
self.assertEqual(outcome, "clean")
|
||||||
|
self.assertTrue(run_git(self.local, "status", "--porcelain"))
|
||||||
|
|
||||||
|
def test_diverged_folder_is_still_backed_up(self):
|
||||||
|
"""Divergence is when work is most at risk -- the least acceptable
|
||||||
|
moment to skip the backup."""
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
client.sync_folder(self.local) # mirror push to establish the branch
|
||||||
|
other = self.other_clone()
|
||||||
|
self.write(other, "b.txt", "remote side")
|
||||||
|
run_git(other, "add", "-A")
|
||||||
|
run_git(other, "commit", "-m", "remote")
|
||||||
|
run_git(other, "push", "origin", "main")
|
||||||
|
remote_sha = run_git(other, "rev-parse", "HEAD")
|
||||||
|
self.write(self.local, "a.txt", "local side")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "local")
|
||||||
|
self.write(self.local, "c.txt", "and uncommitted too")
|
||||||
|
|
||||||
|
outcome, detail = client.sync_folder(self.local, mode="snapshot",
|
||||||
|
dev=self.DEV)
|
||||||
|
|
||||||
|
self.assertEqual(outcome, "diverged")
|
||||||
|
self.assertIn("backed up", detail)
|
||||||
|
self.assertEqual(run_git(self.bare, "rev-parse", "main"), remote_sha)
|
||||||
|
snaps = client.list_snapshots(self.local, self.DEV)
|
||||||
|
self.assertEqual(len(snaps), 1)
|
||||||
|
self.assertIn("c.txt", run_git(self.local, "ls-tree", "--name-only",
|
||||||
|
snaps[0]["sha"]))
|
||||||
|
|
||||||
|
def test_dirty_tree_blocks_the_pull_but_not_the_backup(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
client.sync_folder(self.local)
|
||||||
|
other = self.other_clone()
|
||||||
|
self.write(other, "b.txt", "remote side")
|
||||||
|
run_git(other, "add", "-A")
|
||||||
|
run_git(other, "commit", "-m", "remote")
|
||||||
|
run_git(other, "push", "origin", "main")
|
||||||
|
self.write(self.local, "wip.txt", "half-finished")
|
||||||
|
head_before = run_git(self.local, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
outcome, detail = client.sync_folder(self.local, mode="snapshot",
|
||||||
|
dev=self.DEV)
|
||||||
|
|
||||||
|
self.assertEqual(run_git(self.local, "rev-parse", "HEAD"), head_before)
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.local, "b.txt")))
|
||||||
|
self.assertIn("not pulling", detail)
|
||||||
|
self.assertIn("backed up", detail)
|
||||||
|
|
||||||
|
def test_snapshots_are_listed_from_the_remote(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
self.write(self.local, "a.txt", "wip")
|
||||||
|
ref = client.push_snapshot(self.local, self.DEV)
|
||||||
|
snaps = client.list_snapshots(self.local, self.DEV)
|
||||||
|
self.assertEqual([s["ref"] for s in snaps], [ref])
|
||||||
|
# writing is device-scoped: the ref carries THIS device's id, so two
|
||||||
|
# machines cannot overwrite each other
|
||||||
|
self.assertEqual(snaps[0]["device"], self.DEV)
|
||||||
|
self.assertEqual(client.list_snapshots(self.local, "someone-else"), [])
|
||||||
|
|
||||||
|
def test_a_new_machine_can_see_the_dead_machine_s_backups(self):
|
||||||
|
"""The case the whole feature exists for. Reads must NOT be scoped to
|
||||||
|
this device's id -- a replacement laptop has a new id, and scoping
|
||||||
|
would show an empty list while the backups sit on the forge."""
|
||||||
|
self.write(self.local, "a.txt", "committed")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
self.write(self.local, "a.txt", "PRECIOUS UNCOMMITTED WORK")
|
||||||
|
ref = client.push_snapshot(self.local, "laptop-that-died")
|
||||||
|
|
||||||
|
# a fresh clone standing in for the replacement machine
|
||||||
|
newbox = os.path.join(self.tmp, "newbox")
|
||||||
|
subprocess.run(["git", "clone", "-q", "--origin", "granthi",
|
||||||
|
self.bare, newbox], check=True, capture_output=True,
|
||||||
|
env=GIT_ENV)
|
||||||
|
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||||
|
"token": "t", "device_id": "brand-new-laptop",
|
||||||
|
"folders": {newbox: {"name": "cloud",
|
||||||
|
"full_name": "alice/cloud",
|
||||||
|
"branch": "main",
|
||||||
|
"mode": "snapshot"}}})
|
||||||
|
|
||||||
|
seen = client.list_snapshots(newbox)
|
||||||
|
self.assertEqual([s["ref"] for s in seen], [ref])
|
||||||
|
self.assertEqual(seen[0]["device"], "laptop-that-died")
|
||||||
|
|
||||||
|
# and it can actually restore it
|
||||||
|
dest = os.path.join(self.tmp, "recovered")
|
||||||
|
client.cmd_restore(argparse.Namespace(folder=newbox, at=seen[0]["ts"],
|
||||||
|
into=dest))
|
||||||
|
with open(os.path.join(dest, "a.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "PRECIOUS UNCOMMITTED WORK")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetention(unittest.TestCase):
|
||||||
|
"""Retention is what keeps 30-second backups from being a disk leak --
|
||||||
|
and what must never quietly eat the one restore point someone needs."""
|
||||||
|
|
||||||
|
NOW = client.datetime(2026, 8, 23, 12, 0, 0, tzinfo=client.timezone.utc)
|
||||||
|
|
||||||
|
def snap(self, when):
|
||||||
|
ts = when.strftime(client.SNAPSHOT_TS_FMT)
|
||||||
|
return {"ts": ts, "ref": f"refs/granthi-backup/d/{ts}", "sha": "x"}
|
||||||
|
|
||||||
|
def test_everything_recent_is_kept(self):
|
||||||
|
snaps = [self.snap(self.NOW - client.timedelta(minutes=m))
|
||||||
|
for m in range(0, 24 * 60, 30)]
|
||||||
|
self.assertEqual(client.snapshots_to_prune(snaps, now=self.NOW), [])
|
||||||
|
|
||||||
|
def test_older_than_a_day_thins_to_hourly(self):
|
||||||
|
base = self.NOW - client.timedelta(days=2)
|
||||||
|
snaps = [self.snap(base + client.timedelta(minutes=m))
|
||||||
|
for m in (0, 10, 20, 60, 70)]
|
||||||
|
pruned = client.snapshots_to_prune(snaps, now=self.NOW)
|
||||||
|
self.assertEqual(len(pruned), 3) # 5 in 2 hourly buckets -> keep 2
|
||||||
|
|
||||||
|
def test_older_than_a_week_thins_to_daily(self):
|
||||||
|
base = self.NOW - client.timedelta(days=30)
|
||||||
|
snaps = [self.snap(base + client.timedelta(hours=h))
|
||||||
|
for h in (0, 1, 2, 25)]
|
||||||
|
pruned = client.snapshots_to_prune(snaps, now=self.NOW)
|
||||||
|
self.assertEqual(len(pruned), 2) # 2 days -> keep 1 each
|
||||||
|
|
||||||
|
def test_unparseable_timestamps_are_kept_not_deleted(self):
|
||||||
|
snaps = [{"ts": "not-a-timestamp",
|
||||||
|
"ref": "refs/granthi-backup/d/not-a-timestamp", "sha": "x"}]
|
||||||
|
self.assertEqual(client.snapshots_to_prune(snaps, now=self.NOW), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrune(GitScenarioBase):
|
||||||
|
DEV = "devprune"
|
||||||
|
|
||||||
|
def test_prune_deletes_stale_refs_on_the_remote(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
sha = run_git(self.local, "rev-parse", "HEAD")
|
||||||
|
old = "20260101T000000Z"
|
||||||
|
older = "20260101T001000Z"
|
||||||
|
for ts in (old, older):
|
||||||
|
run_git(self.local, "push", "granthi",
|
||||||
|
f"{sha}:refs/granthi-backup/{self.DEV}/{ts}")
|
||||||
|
self.assertEqual(len(client.list_snapshots(self.local, self.DEV)), 2)
|
||||||
|
|
||||||
|
gone = client.prune_snapshots(self.local, self.DEV)
|
||||||
|
|
||||||
|
self.assertEqual(gone, 1) # same hour, older one dropped
|
||||||
|
left = client.list_snapshots(self.local, self.DEV)
|
||||||
|
self.assertEqual([s["ts"] for s in left], [older])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddGuards(GitScenarioBase):
|
||||||
|
def test_gitignore_is_seeded_only_when_absent(self):
|
||||||
|
self.assertTrue(client.seed_gitignore(self.local))
|
||||||
|
with open(os.path.join(self.local, ".gitignore")) as f:
|
||||||
|
body = f.read()
|
||||||
|
self.assertIn(".env", body)
|
||||||
|
with open(os.path.join(self.local, ".gitignore"), "w") as f:
|
||||||
|
f.write("mine-only\n")
|
||||||
|
self.assertFalse(client.seed_gitignore(self.local))
|
||||||
|
with open(os.path.join(self.local, ".gitignore")) as f:
|
||||||
|
self.assertEqual(f.read(), "mine-only\n")
|
||||||
|
|
||||||
|
def test_seeded_gitignore_keeps_secrets_out_of_snapshots(self):
|
||||||
|
client.seed_gitignore(self.local)
|
||||||
|
self.write(self.local, ".env", "SECRET=hunter2")
|
||||||
|
self.write(self.local, "ok.txt", "fine")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
self.write(self.local, "ok.txt", "changed")
|
||||||
|
commit, tree = client.build_snapshot(self.local)
|
||||||
|
names = run_git(self.local, "ls-tree", "-r", "--name-only", tree)
|
||||||
|
self.assertIn("ok.txt", names)
|
||||||
|
self.assertNotIn(".env", names.splitlines())
|
||||||
|
|
||||||
|
def test_measure_folder_stops_counting_past_the_cap(self):
|
||||||
|
for i in range(12):
|
||||||
|
self.write(self.local, f"f{i}.txt", "x" * 10)
|
||||||
|
files, size = client.measure_folder(self.local, max_files=5)
|
||||||
|
self.assertEqual(files, 6) # bounded: stopped one past the cap
|
||||||
|
self.assertLess(size, 12 * 10)
|
||||||
|
|
||||||
|
def test_measure_folder_ignores_dot_git(self):
|
||||||
|
files, _ = client.measure_folder(self.local)
|
||||||
|
self.assertEqual(files, 0)
|
||||||
|
|
||||||
|
def test_detect_mode(self):
|
||||||
|
plain = os.path.join(self.tmp, "plain")
|
||||||
|
os.makedirs(plain)
|
||||||
|
self.assertEqual(client.detect_mode(plain, had_git=False), "mirror")
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "real history")
|
||||||
|
self.assertEqual(client.detect_mode(self.local, had_git=True),
|
||||||
|
"snapshot")
|
||||||
|
empty = os.path.join(self.tmp, "empty-repo")
|
||||||
|
os.makedirs(empty)
|
||||||
|
client.ensure_repo(empty)
|
||||||
|
self.assertEqual(client.detect_mode(empty, had_git=True), "mirror")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchRepo(unittest.TestCase):
|
||||||
|
def repo(self, full):
|
||||||
|
return {"full_name": full, "name": full.split("/")[-1]}
|
||||||
|
|
||||||
|
def test_substring_is_case_insensitive_and_matches_bare_name(self):
|
||||||
|
self.assertTrue(client.match_repo(self.repo("alice/Notes"), "notes"))
|
||||||
|
self.assertTrue(client.match_repo(self.repo("alice/notes"), "ALICE"))
|
||||||
|
self.assertFalse(client.match_repo(self.repo("alice/notes"), "ledger"))
|
||||||
|
|
||||||
|
def test_glob_syntax_switches_to_glob(self):
|
||||||
|
self.assertTrue(client.match_repo(self.repo("alice/work-2026"),
|
||||||
|
"work-*"))
|
||||||
|
self.assertFalse(client.match_repo(self.repo("alice/homework"),
|
||||||
|
"work-*"))
|
||||||
|
|
||||||
|
def test_empty_pattern_matches_everything(self):
|
||||||
|
self.assertTrue(client.match_repo(self.repo("alice/x"), None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRestore(GitScenarioBase):
|
||||||
|
DEV = "devrestore"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||||
|
"token": "sekrit", "device_id": self.DEV,
|
||||||
|
"folders": {self.local: {
|
||||||
|
"name": "cloud", "full_name": "alice/cloud",
|
||||||
|
"branch": "main", "mode": "snapshot"}}})
|
||||||
|
|
||||||
|
def test_restore_writes_a_new_folder_and_leaves_the_working_tree_alone(self):
|
||||||
|
self.write(self.local, "a.txt", "original")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
self.write(self.local, "a.txt", "the version I want back")
|
||||||
|
ref = client.push_snapshot(self.local, self.DEV)
|
||||||
|
ts = ref.rsplit("/", 1)[-1]
|
||||||
|
self.write(self.local, "a.txt", "what I have now")
|
||||||
|
dest = os.path.join(self.tmp, "restored")
|
||||||
|
|
||||||
|
client.cmd_restore(argparse.Namespace(folder=self.local, at=ts,
|
||||||
|
into=dest))
|
||||||
|
|
||||||
|
with open(os.path.join(dest, "a.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "the version I want back")
|
||||||
|
with open(os.path.join(self.local, "a.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "what I have now")
|
||||||
|
|
||||||
|
def test_restore_refuses_a_non_empty_destination(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
run_git(self.local, "add", "-A")
|
||||||
|
run_git(self.local, "commit", "-m", "c")
|
||||||
|
run_git(self.local, "push", "-u", "granthi", "main")
|
||||||
|
self.write(self.local, "a.txt", "two")
|
||||||
|
ref = client.push_snapshot(self.local, self.DEV)
|
||||||
|
busy = os.path.join(self.tmp, "busy")
|
||||||
|
os.makedirs(busy)
|
||||||
|
with open(os.path.join(busy, "keepme"), "w") as f:
|
||||||
|
f.write("do not clobber")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
client.cmd_restore(argparse.Namespace(
|
||||||
|
folder=self.local, at=ref.rsplit("/", 1)[-1], into=busy))
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(busy, "keepme")))
|
||||||
|
|
||||||
|
def test_unknown_restore_point_is_an_error_not_an_empty_folder(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
client.cmd_restore(argparse.Namespace(
|
||||||
|
folder=self.local, at="20990101T000000Z", into=None))
|
||||||
|
|
||||||
|
def test_restore_refuses_a_folder_that_is_not_linked(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
client.cmd_restore(argparse.Namespace(
|
||||||
|
folder=os.path.join(self.tmp, "nowhere"), at="x", into=None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceId(unittest.TestCase):
|
||||||
|
def test_device_id_is_stable_and_persisted(self):
|
||||||
|
cfg = {}
|
||||||
|
first = client.device_id(cfg)
|
||||||
|
self.assertEqual(client.device_id(cfg), first)
|
||||||
|
self.assertEqual(cfg["device_id"], first)
|
||||||
|
|
||||||
|
def test_two_installs_get_different_ids(self):
|
||||||
|
self.assertNotEqual(client.device_id({}), client.device_id({}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAll(GitScenarioBase):
|
||||||
|
"""--all must pull exactly what the forge grants, and one bad repo must
|
||||||
|
not abandon the rest."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.forge = os.path.join(self.tmp, "forge")
|
||||||
|
for full in ("alice/one", "alice/two"):
|
||||||
|
path = os.path.join(self.forge, full + ".git")
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", "--bare", "-b", "main", path],
|
||||||
|
check=True, capture_output=True, env=GIT_ENV)
|
||||||
|
seed = os.path.join(self.tmp, "seed-" + full.replace("/", "-"))
|
||||||
|
subprocess.run(["git", "clone", path, seed], check=True,
|
||||||
|
capture_output=True, env=GIT_ENV)
|
||||||
|
run_git(seed, "config", "user.name", "s")
|
||||||
|
run_git(seed, "config", "user.email", "s@s")
|
||||||
|
self.write(seed, "f.txt", full)
|
||||||
|
run_git(seed, "add", "-A")
|
||||||
|
run_git(seed, "commit", "-m", "seed")
|
||||||
|
run_git(seed, "push", "origin", "main")
|
||||||
|
client.save_config({"gitea_base": self.forge, "login": "alice",
|
||||||
|
"token": "sekrit", "folders": {}})
|
||||||
|
self.repos = [{"name": "one", "full_name": "alice/one"},
|
||||||
|
{"name": "two", "full_name": "alice/two"}]
|
||||||
|
|
||||||
|
def test_all_clones_every_granted_repo(self):
|
||||||
|
into = os.path.join(self.tmp, "workspace")
|
||||||
|
os.makedirs(into)
|
||||||
|
with mock.patch.object(client, "list_repos",
|
||||||
|
lambda c: (self.repos, False)):
|
||||||
|
rc = client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
for name in ("one", "two"):
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(into, name, "f.txt")))
|
||||||
|
self.assertEqual(len(client.load_config()["folders"]), 2)
|
||||||
|
|
||||||
|
def test_all_skips_what_is_already_here(self):
|
||||||
|
into = os.path.join(self.tmp, "workspace2")
|
||||||
|
os.makedirs(into)
|
||||||
|
with mock.patch.object(client, "list_repos",
|
||||||
|
lambda c: (self.repos, False)):
|
||||||
|
client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
self.assertIn("already present", out.getvalue())
|
||||||
|
self.assertEqual(len(client.load_config()["folders"]), 2)
|
||||||
|
|
||||||
|
def test_all_defaults_cloned_repos_to_snapshot_mode(self):
|
||||||
|
into = os.path.join(self.tmp, "workspace3")
|
||||||
|
os.makedirs(into)
|
||||||
|
with mock.patch.object(client, "list_repos",
|
||||||
|
lambda c: (self.repos, False)):
|
||||||
|
client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
modes = {m["mode"] for m in client.load_config()["folders"].values()}
|
||||||
|
self.assertEqual(modes, {"snapshot"})
|
||||||
|
|
||||||
|
def test_all_says_so_loudly_when_the_listing_was_truncated(self):
|
||||||
|
into = os.path.join(self.tmp, "workspace4")
|
||||||
|
os.makedirs(into)
|
||||||
|
with mock.patch.object(client, "list_repos",
|
||||||
|
lambda c: (self.repos, True)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
self.assertIn("NOT every repo", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkerRoundTrip(GitScenarioBase):
|
||||||
|
"""A plain folder synced on machine A must behave the same on machine B:
|
||||||
|
the intent travels in the repo, not in one machine's config."""
|
||||||
|
|
||||||
|
def test_marker_written_by_add_makes_get_choose_mirror(self):
|
||||||
|
client.write_marker(self.local, "mirror")
|
||||||
|
self.assertEqual(client.read_marker(self.local)["mode"], "mirror")
|
||||||
|
|
||||||
|
def test_missing_or_corrupt_marker_falls_back_to_the_safe_mode(self):
|
||||||
|
self.assertEqual(client.read_marker(self.local), {})
|
||||||
|
with open(os.path.join(self.local, client.MARKER_FILE), "w") as f:
|
||||||
|
f.write("{not json")
|
||||||
|
self.assertEqual(client.read_marker(self.local), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestCredentialHelperIsolation(GitScenarioBase):
|
||||||
|
"""A repo-local helper is not enough on a normal machine: git consults
|
||||||
|
system + global helpers too, and they both shadow us and copy the token
|
||||||
|
into plaintext. Found by live QA against the beta forge, not by a unit
|
||||||
|
test -- so it gets one now."""
|
||||||
|
|
||||||
|
def test_install_leaves_exactly_one_helper(self):
|
||||||
|
run_git(self.local, "config", "--add", "credential.helper", "store")
|
||||||
|
client.install_credential_helper(self.local)
|
||||||
|
# --get-all merges system + global + local, so entries inherited from
|
||||||
|
# the machine still appear. What matters is that the last two are the
|
||||||
|
# reset and ours: git reads an empty value as "forget every helper
|
||||||
|
# inherited so far", so nothing before it can answer.
|
||||||
|
helpers = run_git(self.local, "config", "--get-all",
|
||||||
|
"credential.helper").splitlines()
|
||||||
|
self.assertEqual(helpers[-2], "", helpers)
|
||||||
|
self.assertIn("git-credential", helpers[-1])
|
||||||
|
# the repo-level 'store' this test added is gone, not merely outvoted
|
||||||
|
self.assertNotIn("store", helpers)
|
||||||
|
|
||||||
|
def test_inherited_helper_cannot_answer_for_the_forge(self):
|
||||||
|
"""The end-to-end property: with a poisoned outer helper configured,
|
||||||
|
the credential git actually resolves is ours."""
|
||||||
|
fake = os.path.join(self.tmp, "poison.sh")
|
||||||
|
with open(fake, "w") as f:
|
||||||
|
f.write("#!/bin/sh\n"
|
||||||
|
"echo username=wrong-user\necho password=stale-token\n")
|
||||||
|
os.chmod(fake, 0o755)
|
||||||
|
run_git(self.local, "config", "--add", "credential.helper",
|
||||||
|
f"!{shlex.quote(fake)}")
|
||||||
|
client.save_config({"gitea_base": "http://forge.example:3041",
|
||||||
|
"login": "alice", "token": "the-right-token"})
|
||||||
|
client.install_credential_helper(self.local)
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "-C", self.local, "credential", "fill"],
|
||||||
|
input="protocol=http\nhost=forge.example:3041\n\n",
|
||||||
|
capture_output=True, text=True, env=dict(
|
||||||
|
GIT_ENV, GRANTHI_SYNC_HOME=os.environ["GRANTHI_SYNC_HOME"]))
|
||||||
|
self.assertIn("password=the-right-token", out.stdout)
|
||||||
|
self.assertNotIn("stale-token", out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMirrorRestore(GitScenarioBase):
|
||||||
|
"""Mirror is the default for a plain folder, so its restore path is the
|
||||||
|
one most people will use -- and the timestamp `snapshots` prints has to
|
||||||
|
be a timestamp `restore` accepts, or the user just loops."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||||
|
"token": "t", "device_id": "d1",
|
||||||
|
"folders": {self.local: {
|
||||||
|
"name": "cloud", "full_name": "alice/cloud",
|
||||||
|
"branch": "main", "mode": "mirror"}}})
|
||||||
|
|
||||||
|
def test_restore_accepts_the_timestamp_snapshots_printed(self):
|
||||||
|
self.write(self.local, "a.txt", "the version I want back")
|
||||||
|
client.sync_folder(self.local, mode="mirror")
|
||||||
|
time.sleep(1.1) # %cI has one-second resolution
|
||||||
|
self.write(self.local, "a.txt", "later junk")
|
||||||
|
client.sync_folder(self.local, mode="mirror")
|
||||||
|
|
||||||
|
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_snapshots(argparse.Namespace(folder=self.local,
|
||||||
|
limit=20))
|
||||||
|
listed = [l.split() for l in out.getvalue().splitlines()
|
||||||
|
if l.startswith(" ")]
|
||||||
|
wanted_ts = listed[-1][0] # first column of the oldest entry
|
||||||
|
|
||||||
|
dest = os.path.join(self.tmp, "mirror-restore")
|
||||||
|
client.cmd_restore(argparse.Namespace(folder=self.local,
|
||||||
|
at=wanted_ts, into=dest))
|
||||||
|
with open(os.path.join(dest, "a.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "the version I want back")
|
||||||
|
|
||||||
|
def test_two_commits_in_the_same_second_are_refused_not_guessed(self):
|
||||||
|
"""%cI has one-second resolution. Picking one silently would restore
|
||||||
|
something the user did not choose."""
|
||||||
|
self.write(self.local, "a.txt", "first")
|
||||||
|
client.sync_folder(self.local, mode="mirror")
|
||||||
|
self.write(self.local, "a.txt", "second")
|
||||||
|
client.sync_folder(self.local, mode="mirror")
|
||||||
|
stamps = run_git(self.local, "log", "--format=%cI").splitlines()
|
||||||
|
if len(set(stamps)) != 1:
|
||||||
|
self.skipTest("commits did not land in the same second")
|
||||||
|
with self.assertRaises(SystemExit) as caught:
|
||||||
|
client.cmd_restore(argparse.Namespace(folder=self.local,
|
||||||
|
at=stamps[0], into=None))
|
||||||
|
self.assertIn("matches 2 restore points", str(caught.exception))
|
||||||
|
|
||||||
|
def test_restore_still_accepts_a_sha(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
client.sync_folder(self.local, mode="mirror")
|
||||||
|
sha = run_git(self.local, "rev-parse", "HEAD")
|
||||||
|
dest = os.path.join(self.tmp, "by-sha")
|
||||||
|
client.cmd_restore(argparse.Namespace(folder=self.local, at=sha,
|
||||||
|
into=dest))
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(dest, "a.txt")))
|
||||||
|
|
||||||
|
def test_default_destination_is_a_usable_path(self):
|
||||||
|
self.write(self.local, "a.txt", "one")
|
||||||
|
client.sync_folder(self.local, mode="mirror")
|
||||||
|
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_snapshots(argparse.Namespace(folder=self.local,
|
||||||
|
limit=5))
|
||||||
|
ts = [l.split() for l in out.getvalue().splitlines()
|
||||||
|
if l.startswith(" ")][0][0]
|
||||||
|
client.cmd_restore(argparse.Namespace(folder=self.local, at=ts,
|
||||||
|
into=None))
|
||||||
|
made = [d for d in os.listdir(self.tmp) if d.startswith("local-restore-")]
|
||||||
|
self.assertEqual(len(made), 1, made)
|
||||||
|
self.assertNotIn(":", made[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasureHonoursGitignore(GitScenarioBase):
|
||||||
|
"""The guard tells people to add a .gitignore. That advice has to work."""
|
||||||
|
|
||||||
|
def test_ignored_files_are_not_counted(self):
|
||||||
|
os.makedirs(os.path.join(self.local, "bulkdata"))
|
||||||
|
for i in range(30):
|
||||||
|
self.write(self.local, f"bulkdata/x{i}.bin", "y" * 100)
|
||||||
|
self.write(self.local, "real.txt", "mine")
|
||||||
|
before, _ = client.measure_folder(self.local)
|
||||||
|
self.write(self.local, ".gitignore", "bulkdata/\n")
|
||||||
|
after, _ = client.measure_folder(self.local)
|
||||||
|
self.assertGreater(before, 30)
|
||||||
|
self.assertEqual(after, 2) # real.txt + .gitignore
|
||||||
|
|
||||||
|
def test_measure_leaves_no_git_dir_behind(self):
|
||||||
|
plain = os.path.join(self.tmp, "untouched")
|
||||||
|
os.makedirs(plain)
|
||||||
|
self.write(plain, "a.txt", "x")
|
||||||
|
client.measure_folder(plain)
|
||||||
|
self.assertEqual(os.listdir(plain), ["a.txt"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAllRobustness(GitScenarioBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.forge = os.path.join(self.tmp, "forge")
|
||||||
|
for full in ("alice/notes", "bob/notes"):
|
||||||
|
path = os.path.join(self.forge, full + ".git")
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", path],
|
||||||
|
check=True, capture_output=True, env=GIT_ENV)
|
||||||
|
seed = os.path.join(self.tmp, "seed-" + full.replace("/", "-"))
|
||||||
|
subprocess.run(["git", "clone", "-q", path, seed], check=True,
|
||||||
|
capture_output=True, env=GIT_ENV)
|
||||||
|
run_git(seed, "config", "user.name", "s")
|
||||||
|
run_git(seed, "config", "user.email", "s@s")
|
||||||
|
self.write(seed, "who.txt", full)
|
||||||
|
run_git(seed, "add", "-A")
|
||||||
|
run_git(seed, "commit", "-m", "seed")
|
||||||
|
run_git(seed, "push", "-q", "origin", "main")
|
||||||
|
client.save_config({"gitea_base": self.forge, "login": "alice",
|
||||||
|
"token": "t", "folders": {}})
|
||||||
|
self.repos = [{"name": "notes", "full_name": "alice/notes"},
|
||||||
|
{"name": "notes", "full_name": "bob/notes"}]
|
||||||
|
|
||||||
|
def test_same_named_repos_from_two_owners_both_land(self):
|
||||||
|
into = os.path.join(self.tmp, "ws")
|
||||||
|
os.makedirs(into)
|
||||||
|
with mock.patch.object(client, "list_repos",
|
||||||
|
lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
self.assertIn("name clash", out.getvalue())
|
||||||
|
with open(os.path.join(into, "notes", "who.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "alice/notes")
|
||||||
|
with open(os.path.join(into, "bob-notes", "who.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "bob/notes")
|
||||||
|
self.assertEqual(len(client.load_config()["folders"]), 2)
|
||||||
|
|
||||||
|
def test_a_git_failure_on_one_repo_does_not_abandon_the_rest(self):
|
||||||
|
into = os.path.join(self.tmp, "ws2")
|
||||||
|
os.makedirs(into)
|
||||||
|
real_clone = client.clone_one
|
||||||
|
|
||||||
|
def flaky(cfg, full_name, dest, mode=None):
|
||||||
|
if full_name == "alice/notes":
|
||||||
|
raise RuntimeError("git clone failed: pretend network blip")
|
||||||
|
return real_clone(cfg, full_name, dest, mode)
|
||||||
|
|
||||||
|
with mock.patch.object(client, "list_repos",
|
||||||
|
lambda c: (self.repos, False)), \
|
||||||
|
mock.patch.object(client, "clone_one", flaky), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
rc = client.cmd_get(get_ns(all=True, into=into))
|
||||||
|
self.assertEqual(rc, 1) # reported, not hidden
|
||||||
|
self.assertIn("FAILED alice/notes", out.getvalue())
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(into, "notes", "who.txt")))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPruneClockIsPersisted(GitScenarioBase):
|
||||||
|
def test_watch_once_does_not_prune_every_run(self):
|
||||||
|
client.save_config({"gitea_base": self.tmp, "login": "alice",
|
||||||
|
"token": "t", "device_id": "d1", "folders": {}})
|
||||||
|
with mock.patch.object(client, "prune_snapshots") as pruner:
|
||||||
|
client.watch_pass(now=1_000_000.0)
|
||||||
|
self.assertEqual(client.load_config()["last_prune"], 1_000_000.0)
|
||||||
|
client.watch_pass(now=1_000_060.0) # a minute later: not due
|
||||||
|
client.watch_pass(now=1_003_700.0) # an hour later: due again
|
||||||
|
self.assertEqual(client.load_config()["last_prune"], 1_003_700.0)
|
||||||
|
self.assertEqual(pruner.call_count, 0) # no snapshot folders linked
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user