diff --git a/cmd/graft/add_test.go b/cmd/graft/add_test.go index 15b7b80..8c9c014 100644 --- a/cmd/graft/add_test.go +++ b/cmd/graft/add_test.go @@ -539,6 +539,7 @@ func TestTargetDep_canonicalMatch(t *testing.T) { // spec: REQ-ADD-RESOLVE (an explicit --name that collides with an entry for a // different repo is rejected before any network access — add never silently // re-points an existing name to another repository). +// spec: REQ-ADD-NOREPOINT (the colliding entry is left untouched). func TestAdd_repointWithNameRejected(t *testing.T) { f1, f2 := newFixtureRemote(t), newFixtureRemote(t) dir := newProjectDir(t) diff --git a/docs/design.md b/docs/design.md index ed495c9..f09b256 100644 --- a/docs/design.md +++ b/docs/design.md @@ -175,7 +175,7 @@ The first argument is always a repository path. When updating an existing depend **Entry resolution.** Which entry in `graft.toml` `add` operates on is decided by the following rules. These are all manifest-level validations that happen before any network access; a violation fails with exit code 2: -- With `--name` → target the entry whose name exactly matches the `--name` value: update it if it exists (the repo argument becomes the new `repo` — giving both a name and a repo explicitly is treated as a deliberate re-point); add it if it does not. +- With `--name` → target the entry whose name exactly matches the `--name` value: if it exists and its `repo` matches the argument (compared in canonical form, see §5.4) → update it; if it exists but points at a **different repo** → error — `add` never re-points an existing entry to another repository; changing the repo, like renaming, is the `graft remove` + same-name `graft add` path; if it does not exist → add it. - Without `--name` → match existing entries by normalized repo (canonical form, see §5.4): - exactly one entry → update that entry, keeping its name (even a custom one). - more than one entry (the same repo declared multiple times) → error: list the matching names, suggesting `--name` to disambiguate. @@ -264,7 +264,7 @@ The global cache (§5.4) is invisible in normal use; the following subcommands m - `graft cache dir` — print the cache directory path. - `graft cache verify` — re-hash every store entry; report and delete corrupted entries (exit code 4 if any are found). - `graft cache prune` — remove store entries that no registered link-mode dest references *and* that have not been used recently, plus bare repositories not fetched recently, and report the space reclaimed. Keeping recent entries (an age floor) does two things: it avoids racing a concurrent `apply` (an entry inserted but not yet linked is still recent, so it is never reclaimed underfoot), and it gives copy-mode entries a retention window instead of vanishing on the next prune; an expired entry costs only a re-fetch. Safe to run periodically (and in CI). prune never reclaims a store entry that a live link-mode dest still points at (copy-mode vendors don't depend on the store at all), so under normal operation it does not break existing vendors and needs no `apply` to repair; only if the link registry has drifted out of sync with reality could a live link lose its target, showing as `missing` in `graft status` and repaired by `graft apply`. To wipe the cache entirely, use `graft cache clean` instead. -- `graft cache clean` — remove the entire cache (every bare repository and store entry) and report the space reclaimed. The cache is purely a performance layer, so this is always safe (equivalent to manually removing the directory printed by `graft cache dir`, but cross-platform and without hand-running `rm`). Unlike prune, clean removes the store entries that link-mode vendors point at, leaving their symlinks dangling (`missing` in `graft status`) until `graft apply` re-materializes them (re-fetching as needed); copy-mode vendors are real copies and are unaffected. +- `graft cache clean` — remove the entire cache (every bare repository and store entry) and report the space reclaimed. The cache is purely a performance layer, so this is always safe (equivalent to manually removing the directory printed by `graft cache dir`, but cross-platform and without hand-running `rm`). clean first checks that the cache root contains the `CACHEDIR.TAG` marker graft created, so a `GRAFT_CACHE_DIR` pointing at the wrong directory can never delete user data. Unlike prune, clean removes the store entries that link-mode vendors point at, leaving their symlinks dangling (`missing` in `graft status`) until `graft apply` re-materializes them (re-fetching as needed); copy-mode vendors are real copies and are unaffected. ### 4.8 Environment variables @@ -366,6 +366,7 @@ All downloads flow through a user-level cache (default: the OS user cache direct ``` / +├── CACHEDIR.TAG # standard cache-directory marker (bford.info/cachedir; backup tools skip it) ├── repos///.git # bare repos, incrementally fetched, shared across projects ├── store/sha256/// # immutable file-tree snapshots, keyed by lockfile content hash ├── links/ # registry of link-mode dests (queried by `cache prune`) @@ -375,7 +376,7 @@ All downloads flow through a user-level cache (default: the OS user cache direct **Bare-repo cache.** The key is always the canonical `//` form (scheme, userinfo, and the `.git` suffix stripped), regardless of how `repo` is written — `https://github.com/org/repo`, `github.com/org/repo`, and `git@github.com:org/repo.git` all share one entry. One advisory file lock per repository serializes concurrent fetches into the same bare repo; the rest of the cache is lock-free via atomic renames. Any commit ever fetched can be reinstalled offline. -**Content store.** A store entry is the complete installed tree for a given lockfile `hash`: checked out to `tmp/`, hashed, verified, then atomically renamed into place with all files made read-only. Because the key *is* the hash recorded in `graft.lock`, a store hit needs no network access. Two benefits fall out naturally: `graft lock` fills the store while it computes the hash, so the following `graft apply` installs with no re-download; and content that is byte-for-byte identical — even from different repos or versions — is stored only once per machine. +**Content store.** A store entry is the complete installed tree for a given lockfile `hash`: checked out to `tmp/`, hashed, verified, then atomically renamed into place with all files made read-only. On disk, entries are sharded by the first two hex digits of the hash — the bucket directory is the first 2 digits and the entry directory the remaining 62 (e.g. hash `4e13…` lives at `store/sha256/4e/13…/`); the entry directory does not repeat the full hash. Because the key *is* the hash recorded in `graft.lock`, a store hit needs no network access. Two benefits fall out naturally: `graft lock` fills the store while it computes the hash, so the following `graft apply` installs with no re-download; and content that is byte-for-byte identical — even from different repos or versions — is stored only once per machine. **Materialization.** How a store entry becomes `` is selected by the `GRAFT_LINK_MODE` environment variable. It is a machine-local choice that every materializing command (`apply`, `add`, `remove`) honors identically — there is no per-command flag, and it is never recorded in `graft.toml` or `graft.lock`. For a one-off, set it for a single command (`GRAFT_LINK_MODE=symlink graft apply`). The two mode names (`copy`, `symlink`) mirror uv's link-mode vocabulary; graft deliberately supports only these two: diff --git a/docs/design.zh-TW.md b/docs/design.zh-TW.md index 1b9be8a..d720b23 100644 --- a/docs/design.zh-TW.md +++ b/docs/design.zh-TW.md @@ -173,7 +173,7 @@ graft add [@ref] [--name ] [--subdir ] [--symlinks [@ref] [--name ] [--subdir ] [--symlinks / +├── CACHEDIR.TAG # 標準快取目錄標記(bford.info/cachedir,備份工具據此略過) ├── repos///.git # 裸儲存庫,增量擷取,跨專案共用 ├── store/sha256/// # 不可變的檔案樹快照,以鎖定檔內容雜湊為鍵 ├── links/ # link 模式 dest 的登記簿(供 `cache prune` 查詢) @@ -373,7 +374,7 @@ graft apply **裸儲存庫快取。** 鍵一律是標準化的 `//` 形式(去除 scheme、userinfo 與 `.git` 後綴),與 `repo` 怎麼寫無關——`https://github.com/org/repo`、`github.com/org/repo` 與 `git@github.com:org/repo.git` 全部共用同一條目。每儲存庫一個 advisory file lock,序列化對同一裸儲存庫的並行擷取;快取的其他部分都靠原子 rename 達成無鎖。任何曾經擷取過的 commit 都可離線重新安裝。 -**Content store。** 一個 store 條目就是某個鎖定檔 `hash` 對應的完整安裝樹:先簽出到 `tmp/`、計算雜湊、驗證,再原子 rename 到定位,所有檔案設為唯讀。因為鍵*就是* `graft.lock` 記錄的雜湊,store 命中不需要任何網路存取。兩個好處自然成立:`graft lock` 在計算雜湊的同時就填好了 store,接下來的 `graft apply` 安裝時完全不必重新下載;而完全相同的內容——即使來自不同的 repo 或版本——在每台機器上只儲存一份。 +**Content store。** 一個 store 條目就是某個鎖定檔 `hash` 對應的完整安裝樹:先簽出到 `tmp/`、計算雜湊、驗證,再原子 rename 到定位,所有檔案設為唯讀。磁碟路徑以雜湊前兩碼分桶——桶目錄名是前 2 碼、條目目錄名是其餘 62 碼(例如雜湊 `4e13…` 存於 `store/sha256/4e/13…/`),條目目錄名不重複完整雜湊。因為鍵*就是* `graft.lock` 記錄的雜湊,store 命中不需要任何網路存取。兩個好處自然成立:`graft lock` 在計算雜湊的同時就填好了 store,接下來的 `graft apply` 安裝時完全不必重新下載;而完全相同的內容——即使來自不同的 repo 或版本——在每台機器上只儲存一份。 **具現化。** store 條目如何成為 ``,由 `GRAFT_LINK_MODE` 環境變數選擇。這是機器本地的選擇,所有會具現化的命令(`apply`、`add`、`remove`)一視同仁地遵循它——沒有任何 per-command 旗標,也永遠不會記錄在 `graft.toml` 或 `graft.lock`。若只想單次覆寫,為單一命令設定即可(`GRAFT_LINK_MODE=symlink graft apply`)。兩個模式名稱(`copy`、`symlink`)對齊 uv 的 link 模式詞彙;graft 刻意只支援這兩種: diff --git a/docs/requirements.md b/docs/requirements.md index 07287c8..d22f2a4 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -36,6 +36,7 @@ incrementally; each addition forces a covering test. | REQ-ADD-LATEST | `@latest` / an omitted ref with no matching SemVer tag falls back to the remote `HEAD` with a pseudo-version. | §4.2 | | REQ-ADD-RESOLVE | Ambiguous or name-colliding entry resolution fails with exit code 2 before any network access. | §4.2 | | REQ-ADD-RESYNC | `graft add` re-syncs every entry, not just the targeted one; changes to other deps (a picked-up hand-edit, or repaired vendor drift) are flagged as collateral in the output. | §4.2 | +| REQ-ADD-NOREPOINT | `graft add --name ` fails with exit code 2 and leaves the manifest untouched when `` is already taken by an entry pointing at a different repository — `add` never re-points an existing entry; changing an entry's repo is the `remove` + `add` path. | §4.2 | | REQ-REMOVE-MISSING | `graft remove ` fails with exit code 2 when the name is not in `graft.toml`. | §4.1 | | REQ-LOCK-SHA256-COMMIT | `commit` in `graft.lock` is a non-empty hex string; both 40-char (SHA-1) and 64-char (SHA-256) values are accepted without error. | §3.2 | | REQ-LOCK-VALIDATE | Loading `graft.lock` rejects any entry with an empty `name`, `repo`, or `version`, a `commit` that is not 40 or 64 hex digits, or a `hash` that is not `sha256:` followed by 64 hex digits, with exit code 2 — before any install or hashing, so a truncated or hand-edited lockfile never reaches the content store. | §3.2 | diff --git a/docs/testing.md b/docs/testing.md index 928e933..5433962 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ git ls-remote https://github.com/uber-go/goleak HEAD # Must have external netw |------|----------------|---------| | `github.com/uber-go/goleak` | Has SemVer tags (latest v1.3.0, annotated) | tag / @latest / SHA | | `github.com/uber-go/nilaway` | No tags, contains symlinks (`AGENTS.md`, `CLAUDE.md`) | pseudo-version / symlink rejection and skip | -| `github.com/min0625/mint` | Multiple tags (includes pre-release alpha and stable v0.0.6) | --subdir / multi-tag | +| `github.com/min0625/mint` | Multiple tags (pre-release and stable; exact versions evolve upstream) | --subdir / multi-tag | | `github.com/min0625/graft` | graft itself | dogfood | ## 2. Conventions @@ -60,7 +60,7 @@ Run `$GRAFT init` first, then proceed item by item: | 1 | `add github.com/uber-go/goleak@v1.2.0` | `version = "v1.2.0"` in toml, commit in lock, **vendor install complete**, exit 0 | REQ-ADD-TAG | | 2 | `add github.com/uber-go/goleak@latest` | Select highest non-pre-release tag (v1.3.0), update in place (toml + lock + vendor) | REQ-ADD-LATEST | | 3 | Re-run `add ...@v1.3.0` | no-op, print `already at`, exit 0 | REQ-ADD-NOOP | -| 4 | `add github.com/min0625/mint@main` | Generate pseudo-version `v0.0.0--` | REQ-ADD-PSEUDO | +| 4 | `remove mint`, then `add github.com/min0625/mint@main` | Fresh branch-ref entry → pseudo-version `v0.0.0--`. (The `remove` first matters: on an *existing* entry, when `main` resolves to the already-locked commit the "already at" no-op wins and `version` keeps the tag — per spec §4.2.) | REQ-ADD-PSEUDO | | 5 | `add github.com/uber-go/nilaway` | **exit 2**, error includes symlink path `AGENTS.md`; toml does not keep partial entries | REQ-HASH-SYMLINK-PATH | | 6 | `add github.com/uber-go/nilaway --symlinks=skip` | Print warning for each symlink, success, entry writes `symlinks = "skip"` | REQ-ADD-SYMLINKS, REQ-DEP-SYMLINKS | | 7 | `add github.com/min0625/mint@v0.0.6 --subdir internal --name mint-internal` | Install subdirectory only, exit 0 | — | @@ -134,7 +134,7 @@ REQ: REQ-STATUS-STATES, REQ-STATUS-EXIT. status is read-only, no network. | # | Command | Expected | |---|---------|----------| -| 1 | `cache dir` | Print `$GRAFT_CACHE_DIR`; directory structure is `links/locks/repos/store/tmp` | +| 1 | `cache dir` | Print `$GRAFT_CACHE_DIR`; directory structure is `links/locks/repos/store/tmp` plus a `CACHEDIR.TAG` marker (backup tools skip it; `cache clean` requires it before deleting) | | 2 | `cache verify` | Re-hash all store entries, exit 0 if clean | | 3 | Tamper with a store entry file then `cache verify` | **exit 4**, remove corrupted entry; verify again to be clean. **Note**: store files are read-only by default (mode 444); run `chmod u+w ` before tampering | | 4 | Tamper with a store entry file, delete the vendor dir, then `apply` | **exit 4** ("cached content … is corrupted"), nothing installed, the corrupted entry is removed; a second `apply` re-fetches and succeeds (REQ-STORE-INSTALL-VERIFY) |