Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/graft/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -366,6 +366,7 @@ All downloads flow through a user-level cache (default: the OS user cache direct

```
<cache>/
├── CACHEDIR.TAG # standard cache-directory marker (bford.info/cachedir; backup tools skip it)
├── repos/<host>/<org>/<repo>.git # bare repos, incrementally fetched, shared across projects
├── store/sha256/<xx>/<hex…>/ # immutable file-tree snapshots, keyed by lockfile content hash
├── links/ # registry of link-mode dests (queried by `cache prune`)
Expand All @@ -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 `<host>/<org>/<repo>` 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 `<dest>` 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:

Expand Down
7 changes: 4 additions & 3 deletions docs/design.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ graft add <repo>[@ref] [--name <name>] [--subdir <dir>] [--symlinks <reject|skip

**條目指認。** `add` 要操作 `graft.toml` 中的哪一個條目,依下列規則決定。這些全是清單層級的驗證,發生在任何網路存取之前;違反時以結束碼 2 失敗:

- 帶 `--name` → 目標為名稱與 `--name` 值完全相符的條目:存在即更新(repo 引數成為新的 `repo`——同時明確給出名稱與 repo,視為有意重指向);不存在則新增
- 帶 `--name` → 目標為名稱與 `--name` 值完全相符的條目:存在且其 `repo`(以標準形式比對,見 §5.4)與引數相同 → 更新;存在但指向**不同 repo** → 錯誤——`add` 永遠不會把既有條目重指向另一個 repo,要更換 repo,與改名相同:先 `graft remove` 再以同名 `graft add`;不存在 → 新增
- 不帶 `--name` → 以正規化後的 repo(標準形式見 §5.4)比對既有條目:
- 恰好一個條目 → 更新該條目,名稱保留(即使是自訂名稱)。
- 多於一個條目(同一 repo 宣告多次)→ 錯誤:列出符合的名稱,提示改用 `--name` 指明目標。
Expand Down Expand Up @@ -262,7 +262,7 @@ graft add <repo>[@ref] [--name <name>] [--subdir <dir>] [--symlinks <reject|skip
- `graft cache dir` — 輸出快取目錄路徑。
- `graft cache verify` — 重新雜湊每個 store 條目;回報並刪除損壞的條目(若有發現則結束碼 4)。
- `graft cache prune` — 移除「沒有任何已登記 link 模式 dest 引用、且近期未被使用」的 store 條目,以及近期未被擷取的裸儲存庫,並回報回收的空間。保留近期條目(年齡下限)有兩個作用:避免與並行的 `apply` 競態(剛寫入但尚未建立連結的條目仍屬「近期」,不會被回收)、並讓 copy 模式條目有保留期而非下次 prune 就消失;過期條目最多只是重新擷取一次。可安全地定期執行(包含 CI)。prune 不會回收任何 live link 模式 dest 仍指向的 store 條目(copy 模式 vendor 本就不依賴 store),因此正常情況下不會弄壞現有 vendor、也不需要 `apply` 修復;只有當 link 登記簿與實際連結不一致時,某個 live link 才可能失去目標,在 `graft status` 中顯示為 `missing`,由 `graft apply` 重新具現化。若要整個清空,改用 `graft cache clean`。
- `graft cache clean` — 移除整個快取(所有裸儲存庫與 store 條目),並回報回收的空間。快取純為效能層,隨時可安全執行(等價於手動刪除 `graft cache dir` 印出的目錄,但跨平台且免於手打 `rm`)。與 prune 不同,clean 會移除 link 模式 vendor 所指向的 store 條目,使其 symlink 懸空(在 `graft status` 顯示為 `missing`),需 `graft apply` 重新具現化(必要時重新擷取);copy 模式 vendor 為真副本,不受影響。
- `graft cache clean` — 移除整個快取(所有裸儲存庫與 store 條目),並回報回收的空間。快取純為效能層,隨時可安全執行(等價於手動刪除 `graft cache dir` 印出的目錄,但跨平台且免於手打 `rm`)。clean 會先確認快取根目錄含有 graft 建立的 `CACHEDIR.TAG` 標記才動手,因此 `GRAFT_CACHE_DIR` 誤指向其他目錄時不會誤刪使用者資料。與 prune 不同,clean 會移除 link 模式 vendor 所指向的 store 條目,使其 symlink 懸空(在 `graft status` 顯示為 `missing`),需 `graft apply` 重新具現化(必要時重新擷取);copy 模式 vendor 為真副本,不受影響。

### 4.8 環境變數

Expand Down Expand Up @@ -364,6 +364,7 @@ graft apply

```
<cache>/
├── CACHEDIR.TAG # 標準快取目錄標記(bford.info/cachedir,備份工具據此略過)
├── repos/<host>/<org>/<repo>.git # 裸儲存庫,增量擷取,跨專案共用
├── store/sha256/<xx>/<hex…>/ # 不可變的檔案樹快照,以鎖定檔內容雜湊為鍵
├── links/ # link 模式 dest 的登記簿(供 `cache prune` 查詢)
Expand All @@ -373,7 +374,7 @@ graft apply

**裸儲存庫快取。** 鍵一律是標準化的 `<host>/<org>/<repo>` 形式(去除 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 條目如何成為 `<dest>`,由 `GRAFT_LINK_MODE` 環境變數選擇。這是機器本地的選擇,所有會具現化的命令(`apply`、`add`、`remove`)一視同仁地遵循它——沒有任何 per-command 旗標,也永遠不會記錄在 `graft.toml` 或 `graft.lock`。若只想單次覆寫,為單一命令設定即可(`GRAFT_LINK_MODE=symlink graft apply`)。兩個模式名稱(`copy`、`symlink`)對齊 uv 的 link 模式詞彙;graft 刻意只支援這兩種:

Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo> --name <name>` fails with exit code 2 and leaves the manifest untouched when `<name>` 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 <name>` 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 |
Expand Down
6 changes: 3 additions & 3 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<ts>-<sha12>` | REQ-ADD-PSEUDO |
| 4 | `remove mint`, then `add github.com/min0625/mint@main` | Fresh branch-ref entry → pseudo-version `v0.0.0-<ts>-<sha12>`. (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 | — |
Expand Down Expand Up @@ -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 <file>` 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) |
Expand Down
Loading