diff --git a/CLAUDE.md b/CLAUDE.md
index ae290b3..0f56c61 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,10 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
A language-agnostic dependency manager for git repositories (Go, single binary) — a replacement for git submodules. Users declare deps in `graft.toml`, graft resolves them go.mod-style (tags or pseudo-versions) and pins exact commit SHAs plus content hashes in `graft.lock`; `graft apply` reconciles the vendor directory to match the lockfile exactly.
-**Current status: Milestones 1–4 implemented** — the full CLI (`init`/`add`/`remove`/`apply`/`lock`/`status`/`cache`), parallel apply, the per-project advisory lock, golden output tests, the release pipeline (`.goreleaser.yaml`, `install.sh`), and the global cache layer: the shared bare-repo cache (`internal/repocache`), the content-addressed store (`internal/store`, with reflink + `graft cache verify`/`prune`/`clean`), and link mode (`GRAFT_LINK_MODE`, registered in `internal/links`). Milestone 5 (ecosystem docs) is not implemented yet; the README marks that section as planned. The release pipeline is proven end-to-end (T3.4 done): `v0.0.1-beta.1` is published with multi-platform artifacts + `SHA256SUMS`. The project intentionally stays on a 0.x cadence (v0.0.1, v0.0.2, …) until it is very stable before cutting v1, so breaking changes are still permitted pre-1.0. Homebrew packaging is dropped for now — script install only. The source of truth is the design spec: the normative design (§1–§9) lives in the version-controlled `docs/design.zh-TW.md` (authoritative) with an English translation in `docs/design.md` — read it before implementing anything. Docs that must stay consistent with the spec: `docs/design.md` (English translation — keep in sync with the zh-TW doc), `docs/requirements.md` (the traceability matrix — see below), `docs/testing.md` (black-box testing manual), `README.md`, `README.zh-TW.md`. When changing behavior or design, update all of them.
+**Current status: Milestones 1–4 implemented** — the full CLI (`init`/`add`/`remove`/`apply`/`lock`/`status`/`cache`), parallel apply, the per-project advisory lock, golden output tests, the release pipeline (`.goreleaser.yaml`, `install.sh`), and the global cache layer: the shared bare-repo cache (`internal/repocache`), the content-addressed store (`internal/store`, with reflink + `graft cache verify`/`prune`/`clean`), and link mode (`GRAFT_LINK_MODE`, registered in `internal/links`). Milestone 5 (ecosystem docs) is mostly done — the README already has CI usage examples (GitHub Actions, GitLab CI) and a comparison section; the documentation site remains unbuilt. The release pipeline is proven end-to-end (T3.4 done): `v0.0.1-beta.1` is published with multi-platform artifacts + `SHA256SUMS`. The project intentionally stays on a 0.x cadence (v0.0.1, v0.0.2, …) until it is very stable before cutting v1, so breaking changes are still permitted pre-1.0. Homebrew packaging is dropped for now — script install only. The source of truth is the design spec: the normative design (§1–§9) lives in the version-controlled `docs/design.zh-TW.md` (authoritative) with an English translation in `docs/design.md` — read it before implementing anything. Docs that must stay consistent with the spec: `docs/design.md` (English translation — keep in sync with the zh-TW doc), `docs/requirements.md` (the traceability matrix — see below), `docs/testing.md` (black-box testing manual), `README.md`, `README.zh-TW.md`. When changing behavior or design, update all of them.
Spec ↔ implementation conformance is machine-enforced by two tests:
-- `internal/clierr/spec_test.go` binds the §4.5 exit-code table in both design docs to the `clierr` constants; it fails if spec and implementation (or the two translations) drift.
+- `internal/clierr/spec_test.go` binds the §4.6 exit-code table in both design docs to the `clierr` constants; it fails if spec and implementation (or the two translations) drift.
- `internal/clierr/spec_coverage_test.go` parses `docs/requirements.md` (a flat table of `REQ--` normative requirements) and scans `*_test.go` for `REQ-…` references (by convention in a `// spec: REQ-…` comment). It fails if any requirement has no covering test, or any test references an unknown ID. Adding a row to `requirements.md` therefore forces a covering test — this is the ratchet that drives spec coverage. The table currently covers the core install loop; remaining sections are filled in incrementally.
## Commands
@@ -18,7 +18,7 @@ Tool versions are managed by [mise](https://mise.jdx.dev/) ([mise.toml](mise.tom
```bash
make check # check-tidy + lint (CI runs tests as a separate step)
-make test # go test -race -failfast -v ./...
+make test # go test -race -failfast ./...
make lint # golangci-lint config verify + run -v
make fix # go mod tidy + golangci-lint --fix
make check-tidy # go mod tidy -diff
@@ -49,7 +49,7 @@ Key invariants from the spec:
- `graft apply` never modifies the lockfile; `graft status` is read-only and makes no network requests.
- Mutating commands take a per-project advisory lock stored in the global cache (`GRAFT_CACHE_DIR`), never in the repo.
- The global cache (bare repos + content store keyed by hash) is purely a performance layer — deleting it must always be safe.
-- Defined exit codes (spec §4.5): e.g. 2 = lockfile missing/out of sync, 4 = integrity (hash) failure.
+- Defined exit codes (spec §4.6): e.g. 2 = lockfile missing/out of sync, 4 = integrity (hash) failure.
## Conventions
diff --git a/README.md b/README.md
index 2652791..dd1f411 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ $ graft apply
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/min0625/graft/main/script/install.sh)"
```
-Auto-detects OS and architecture (Linux/macOS, x86_64/arm64), installs to `~/.local/bin`. Override with `GRAFT_INSTALL_DIR` or pin a version with `GRAFT_VERSION=v1.0.0`.
+Auto-detects OS and architecture (Linux/macOS, x86_64/arm64), installs to `~/.local/bin`. Override with `GRAFT_INSTALL_DIR` or pin a version with `GRAFT_VERSION=v0.0.1-beta.1` (any tag on the [releases page](https://github.com/min0625/graft/releases)).
**Windows (PowerShell)**
@@ -55,7 +55,7 @@ Auto-detects OS and architecture (Linux/macOS, x86_64/arm64), installs to `~/.lo
irm https://raw.githubusercontent.com/min0625/graft/main/script/install.ps1 | iex
```
-Installs to `$HOME\.local\bin`. Override with `$env:GRAFT_INSTALL_DIR` or pin a version with `$env:GRAFT_VERSION = 'v1.0.0'`.
+Installs to `$HOME\.local\bin`. Override with `$env:GRAFT_INSTALL_DIR` or pin a version with `$env:GRAFT_VERSION = 'v0.0.1-beta.1'`.
### Manual download
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 2f08c6a..aa4c3fd 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -47,7 +47,7 @@ $ graft apply
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/min0625/graft/main/script/install.sh)"
```
-自動偵測 OS 與架構(Linux/macOS、x86_64/arm64),安裝到 `~/.local/bin`。可用 `GRAFT_INSTALL_DIR` 覆寫安裝位置,或用 `GRAFT_VERSION=v1.0.0` 釘選版本。
+自動偵測 OS 與架構(Linux/macOS、x86_64/arm64),安裝到 `~/.local/bin`。可用 `GRAFT_INSTALL_DIR` 覆寫安裝位置,或用 `GRAFT_VERSION=v0.0.1-beta.1` 釘選版本(可用 [releases 頁面](https://github.com/min0625/graft/releases)上的任一 tag)。
**Windows(PowerShell)**
@@ -55,7 +55,7 @@ $ graft apply
irm https://raw.githubusercontent.com/min0625/graft/main/script/install.ps1 | iex
```
-安裝到 `$HOME\.local\bin`。可用 `$env:GRAFT_INSTALL_DIR` 覆寫,或用 `$env:GRAFT_VERSION = 'v1.0.0'` 釘選版本。
+安裝到 `$HOME\.local\bin`。可用 `$env:GRAFT_INSTALL_DIR` 覆寫,或用 `$env:GRAFT_VERSION = 'v0.0.1-beta.1'` 釘選版本。
### 手動下載
@@ -153,13 +153,13 @@ graft add github.com/your-org/shared-scripts@v1.3.0 # 以 repo URL 更新現
| 你傳入 | `version` 變成 |
|---|---|
-| tag(`@v1.2.0`) | tag 名稱,原樣記錄 |
+| tag(`@v1.2.0`) | tag 名稱,原樣記錄 |
| 分支或 SHA(`@main`、`@a3f8c21d`) | 形如 `v0.0.0-20260418091327-a3f8c21d4e8f` 的 pseudo-version |
-| 省略 | 最新的 semver tag(若沒有 tag,則為遠端 `HEAD` 的 pseudo-version) |
+| 省略 | 最新的 semver tag(若沒有 tag,則為遠端 `HEAD` 的 pseudo-version) |
-無論哪一種,確切的 commit SHA 與內容雜湊都寫入 `graft.lock`,而安裝**永遠只依據它們**——之後分支移動或 tag 被重新指向都無法改變安裝結果。
+無論哪一種,確切的 commit SHA 與內容雜湊都寫入 `graft.lock`,而安裝**永遠只依據它們**——之後分支移動或 tag 被重新指向都無法改變安裝結果。
-若依賴已鎖定在相同的 commit,此命令為無操作。`graft add` 會以重新同步*整個*鎖定檔與 vendor 樹收尾(等同 `graft lock` + `graft apply`),因此你對 `graft.toml` 中其他依賴的手動編輯也會在同一次執行中被一併處理。當這次重新同步改動到你指定以外的任何依賴時,`graft add` 會印出 `also synced other dependencies:` 區塊將它們列出,讓連帶變更不會藏在你所要求那個依賴的訊息行背後:
+若依賴已鎖定在相同的 commit,此命令為無操作。`graft add` 會以重新同步*整個*鎖定檔與 vendor 樹收尾(等同 `graft lock` + `graft apply`),因此你對 `graft.toml` 中其他依賴的手動編輯也會在同一次執行中被一併處理。當這次重新同步改動到你指定以外的任何依賴時,`graft add` 會印出 `also synced other dependencies:` 區塊將它們列出,讓連帶變更不會藏在你所要求那個依賴的訊息行背後:
```bash
$ graft add github.com/your-org/shared-scripts@v1.3.0
@@ -200,7 +200,7 @@ graft add github.com/your-org/shared-scripts@main # 把釘選的分支重
graft add github.com/your-org/shared-scripts # 更新到最新的 semver tag
```
-每次更新都會在 `graft.toml` 顯示為一行 `version` 變更。tag 升級也可以手動改 `graft.toml` 的 `version` 再跑 `graft lock`;pseudo-version 無法手算,那種情況請重跑 `graft add`。當多個條目共用同一個 repo 時,加上 `--name` 指定要更新哪一個。
+每次更新都會在 `graft.toml` 顯示為一行 `version` 變更。tag 升級也可以手動改 `graft.toml` 的 `version` 再跑 `graft lock`;pseudo-version 無法手算,那種情況請重跑 `graft add`。當多個條目共用同一個 repo 時,加上 `--name` 指定要更新哪一個。
---
@@ -260,14 +260,14 @@ $ graft status
✗ proto-defs b7e1209 (v0.8.1) modified
```
-最後一欄的狀態為下列其一:
+最後一欄的狀態為下列其一:
| 狀態 | 意義 |
|---|---|
| `ok` | 已安裝且與鎖定檔相符 |
-| `missing` | 鎖定檔中有,但 vendor 目錄中缺少 |
+| `missing` | 鎖定檔中有,但 vendor 目錄中缺少 |
| `modified` | vendor 內容與鎖定的雜湊不符(例如手動改過檔案) |
-| `extra` | vendor 目錄中有,但鎖定檔中沒有 |
+| `extra` | vendor 目錄中有,但鎖定檔中沒有 |
| `out of sync` | `graft.toml` 與 `graft.lock` 不一致(執行 `graft lock`) |
全部同步時以結束碼 0 退出;純 vendor 偏移(missing/modified/extra)為 1;`graft.toml` 與 `graft.lock` 不一致時為 2(與 `graft lock --check`、`graft apply` 相同的鎖定檔同步失敗碼;兩者同時發生時取較大值)——很適合在 CI 中防止 vendor 檔案被手動修改。沒有任何依賴時印出 `✓ no dependencies`。link 模式的 dest 以低成本的連結目標比對驗證(store 為不可變;如需重新雜湊 store 條目,請使用 `graft cache verify`)。以另一種模式具現化的 dest——copy 模式下的 symlink、link 模式下的實體樹——回報 `modified`:那正是 `graft apply` 會重寫的偏移。
@@ -450,17 +450,17 @@ graft cache clean # 移除整個快取
## 常見問題
-**怎麼更新依賴?** 再跑一次 `graft add`——見[更新依賴](#更新依賴)。沒有獨立的 `update` 命令。
+**怎麼更新依賴?** 再跑一次 `graft add`——見[更新依賴](#更新依賴)。沒有獨立的 `update` 命令。
-**graft 會解析傳遞依賴嗎?** 不會。graft 只管理你明確宣告的頂層依賴——依賴自己的 `graft.toml`(若有)會被忽略。這讓解析保持簡單透明;你需要的依賴請全部自己宣告。
+**graft 會解析傳遞依賴嗎?** 不會。graft 只管理你明確宣告的頂層依賴——依賴自己的 `graft.toml`(若有)會被忽略。這讓解析保持簡單透明;你需要的依賴請全部自己宣告。
-**上游刪掉或重指 tag 會怎樣?** 已安裝的依賴不受影響——`graft apply` 是依 `graft.lock` 裡的 commit SHA 安裝,而非 tag,因此即使 tag 移動或消失仍能運作。只有當你針對該依賴重跑 `graft add`/`graft lock` 時才會碰到遠端。
+**上游刪掉或重指 tag 會怎樣?** 已安裝的依賴不受影響——`graft apply` 是依 `graft.lock` 裡的 commit SHA 安裝,而非 tag,因此即使 tag 移動或消失仍能運作。只有當你針對該依賴重跑 `graft add`/`graft lock` 時才會碰到遠端。
-**可以 vendor 同一個 monorepo 的多個子目錄嗎?** 可以——把 repo 加入多次,每次給各自的 `--name` 與 `--subdir`。見 [`graft add`](#graft-addreporef)。
+**可以 vendor 同一個 monorepo 的多個子目錄嗎?** 可以——把 repo 加入多次,每次給各自的 `--name` 與 `--subdir`。見 [`graft add`](#graft-addreporef)。
-**怎麼列出我的依賴 / 確認它們完好?** `graft status` 會印出每個依賴的釘選 commit 與同步狀態(`ok` / `missing` / `modified` / `extra` / `out of sync`),唯讀且離線。可當作 CI 守門,確保安裝目錄沒被手動改過。
+**怎麼列出我的依賴 / 確認它們完好?** `graft status` 會印出每個依賴的釘選 commit 與同步狀態(`ok` / `missing` / `modified` / `extra` / `out of sync`),唯讀且離線。可當作 CI 守門,確保安裝目錄沒被手動改過。
-**要不要把安裝目錄提交進版控?** 兩種都可以。`.gitignore` 掉走一般套件管理流程(`graft apply` 會重建它),或提交它以支援離線/可重現建置——見 [.gitignore](#gitignore)。
+**要不要把安裝目錄提交進版控?** 兩種都可以。`.gitignore` 掉走一般套件管理流程(`graft apply` 會重建它),或提交它以支援離線/可重現建置——見 [.gitignore](#gitignore)。
---
@@ -482,7 +482,7 @@ vdm 沒有鎖定檔 — 如果你固定到一個分支,不同天你會得到
## 設計文件
-完整的設計與行為規範請見 [`docs/design.zh-TW.md`](docs/design.zh-TW.md)(檔案格式、命令語義、結束碼、架構、安全考慮與測試策略)。
+完整的設計與行為規範請見 [`docs/design.zh-TW.md`](docs/design.zh-TW.md)(權威版)與其英文翻譯 [`docs/design.md`](docs/design.md)——檔案格式、命令語義、結束碼、架構、安全考慮與測試策略。
---
diff --git a/cmd/graft/apply.go b/cmd/graft/apply.go
index bd9462f..ab88f57 100644
--- a/cmd/graft/apply.go
+++ b/cmd/graft/apply.go
@@ -74,8 +74,8 @@ graft.toml or graft.lock.`,
}
// checkSync verifies that graft.toml and graft.lock agree, by pure string
-// comparison of every dep's version, repo, path, and resolved dest
-// (spec §4.3) — no network.
+// comparison of every dep's version, repo, subdir, symlinks, and dest
+// (spec §4.4) — no network.
func checkSync(m *config.Manifest, lf *lockfile.Lockfile) error {
var diffs []string
diff --git a/cmd/graft/apply_test.go b/cmd/graft/apply_test.go
index b979cfe..5bacb53 100644
--- a/cmd/graft/apply_test.go
+++ b/cmd/graft/apply_test.go
@@ -81,7 +81,7 @@ func TestApply_outOfSync(t *testing.T) {
}
// TestApply_repairsTamperedVendor: apply reconciles — a hand-edited vendor
-// tree is drift and is reinstalled from the locked commit (spec §4.3, §10.5).
+// tree is drift and is reinstalled from the locked commit (spec §4.4, §10.5).
func TestApply_repairsTamperedVendor(t *testing.T) {
f := newFixtureRemote(t)
dir := newProjectDir(t)
@@ -144,7 +144,7 @@ func multiDepManifest(f *fixtureRemote, n int) string {
}
// TestApply_multiDepParallel: a fresh install of several deps exercises the
-// spec §5.4 worker pool; the suite runs under -race, so any data race in the
+// spec §5.2 worker pool; the suite runs under -race, so any data race in the
// parallel reconcile fails the test. All deps point at the same upstream repo,
// so the per-repo advisory lock inside the fetcher serializes access to the
// bare-repo cache — proven correct by the -race detector.
@@ -175,7 +175,7 @@ func TestApply_multiDepParallel(t *testing.T) {
}
// TestApply_collectsAllErrors: errors are collected across the worker pool
-// and reported together (spec §5.4 — no fail-fast), so doctoring the hash of
+// and reported together (spec §5.2 — no fail-fast), so doctoring the hash of
// two deps surfaces two integrity errors in a single run.
func TestApply_collectsAllErrors(t *testing.T) {
f := newFixtureRemote(t)
@@ -222,7 +222,7 @@ func TestApply_removesExtraAfterDepRemoval(t *testing.T) {
// TestApply_offlineFromStore checks the content store: `graft lock` populates
// it, so a later `graft apply` installs with no network at all — proven by
-// removing the remote before applying (spec §5.6).
+// removing the remote before applying (spec §5.4).
func TestApply_offlineFromStore(t *testing.T) {
f := newFixtureRemote(t)
dir := newProjectDir(t)
diff --git a/cmd/graft/cache_test.go b/cmd/graft/cache_test.go
index f15e4f1..a0118e9 100644
--- a/cmd/graft/cache_test.go
+++ b/cmd/graft/cache_test.go
@@ -155,7 +155,7 @@ func TestCache_pruneKeepsRecentUnreferenced(t *testing.T) {
}
}
-// TestCache_pruneReclaimsAfterLinkRewrite covers spec §5.6: a store entry kept
+// TestCache_pruneReclaimsAfterLinkRewrite covers spec §5.4: a store entry kept
// alive by a link-mode dest must become reclaimable once that dest is rewritten
// to a copy, so the now-stale link registration no longer pins it.
func TestCache_pruneReclaimsAfterLinkRewrite(t *testing.T) {
diff --git a/cmd/graft/cli_test.go b/cmd/graft/cli_test.go
index 7717937..d5763e0 100644
--- a/cmd/graft/cli_test.go
+++ b/cmd/graft/cli_test.go
@@ -68,7 +68,7 @@ func wantExit(t *testing.T, err error, code clierr.Code) {
// newProjectDir creates a fresh directory with a .git marker — so project
// root discovery can never walk above it — and makes it the working
-// directory. The global cache (project locks, spec §5.6) is pointed at a
+// directory. The global cache (project locks, spec §5.4) is pointed at a
// per-test directory so tests never touch the real user cache.
func newProjectDir(t *testing.T) string {
t.Helper()
diff --git a/cmd/graft/golden_test.go b/cmd/graft/golden_test.go
index d9344f1..0a4698a 100644
--- a/cmd/graft/golden_test.go
+++ b/cmd/graft/golden_test.go
@@ -300,7 +300,7 @@ func TestGolden_applyIntegrity(t *testing.T) {
})
}
-// TestGolden_applyIntegrityMulti: the spec §5.4 parallel reconcile collects
+// TestGolden_applyIntegrityMulti: the spec §5.2 parallel reconcile collects
// errors — both integrity failures land in one transcript.
//
// spec: REQ-PARALLEL-COLLECT
diff --git a/cmd/graft/link_test.go b/cmd/graft/link_test.go
index 2ec7ec4..eeee7d3 100644
--- a/cmd/graft/link_test.go
+++ b/cmd/graft/link_test.go
@@ -61,7 +61,7 @@ func TestApply_invalidLinkMode(t *testing.T) {
}
}
-// TestApply_modeSwitchRewrites covers spec §5.6: a dest materialized in one
+// TestApply_modeSwitchRewrites covers spec §5.4: a dest materialized in one
// mode is treated as drift and rewritten when the other mode is applied.
func TestApply_modeSwitchRewrites(t *testing.T) {
f := newFixtureRemote(t)
@@ -136,7 +136,7 @@ func TestStatus_modeDriftReportsModified(t *testing.T) {
}
// TestApply_linkRestoredAfterCacheWipe covers the clean→missing→re-apply loop
-// of spec §4.6: a removed store entry leaves the link dangling (status
+// of spec §4.7: a removed store entry leaves the link dangling (status
// missing), and apply re-materializes it. The cache is always safe to delete,
// so wiping it is just an os.RemoveAll of the cache directory.
func TestApply_linkRestoredAfterCacheWipe(t *testing.T) {
diff --git a/cmd/graft/project.go b/cmd/graft/project.go
index 44bdca9..bebfda6 100644
--- a/cmd/graft/project.go
+++ b/cmd/graft/project.go
@@ -36,7 +36,7 @@ func openProject() (*project, error) {
return p, err
}
-// openProjectLocked is openProject for mutating commands (spec §5.7): it
+// openProjectLocked is openProject for mutating commands (spec §5.5): it
// additionally takes the per-project advisory lock — before reading
// graft.toml — printing a wait hint to the command's stderr when another
// graft process holds it. The caller must defer release.
@@ -145,7 +145,7 @@ func installOptions(mode vendordir.Mode) (vendordir.Options, error) {
}, nil
}
-// resolveMode resolves the machine-local materialization mode (spec §5.6,
+// resolveMode resolves the machine-local materialization mode (spec §5.4,
// §10.11) from GRAFT_LINK_MODE, defaulting to copy. The mode vocabulary aligns
// with uv; graft supports only copy and symlink. It is a per-machine choice
// that every materializing command honors identically, never read from
@@ -167,7 +167,7 @@ func resolveMode() (vendordir.Mode, error) {
// reconcile brings the vendor directory in line with lf and returns what
// changed; callers narrate the result with printReconcile. Installs flow
-// through the global cache: a store hit (spec §5.6) needs no network, and a
+// through the global cache: a store hit (spec §5.4) needs no network, and a
// miss fetches into the cache staging area before publishing to the store.
func (p *project) reconcile(
ctx context.Context, lf *lockfile.Lockfile, mode vendordir.Mode,
diff --git a/cmd/graft/projlock_test.go b/cmd/graft/projlock_test.go
index 634e632..3aa973f 100644
--- a/cmd/graft/projlock_test.go
+++ b/cmd/graft/projlock_test.go
@@ -35,7 +35,7 @@ func (s *syncBuffer) String() string {
// TestProjectLock_blocksSecondProcess: while one graft process holds the
// per-project lock, a second mutating command blocks — printing the spec
-// §5.7 wait hint after a second — instead of failing, and completes once the
+// §5.5 wait hint after a second — instead of failing, and completes once the
// lock is released. graft status takes no lock and is never blocked.
func TestProjectLock_blocksSecondProcess(t *testing.T) {
f := newFixtureRemote(t)
@@ -50,7 +50,7 @@ func TestProjectLock_blocksSecondProcess(t *testing.T) {
t.Fatal(err)
}
- // Read-only status must not block on the held lock (spec §5.7).
+ // Read-only status must not block on the held lock (spec §5.5).
if out := mustRunGraft(t, "status"); !strings.Contains(out, "ok") {
t.Errorf("status while locked = %q", out)
}
diff --git a/cmd/graft/relock.go b/cmd/graft/relock.go
index 0e377f1..2274ec1 100644
--- a/cmd/graft/relock.go
+++ b/cmd/graft/relock.go
@@ -117,7 +117,7 @@ func newEntry(dep config.Dep, commit string, commitTime time.Time, hash string)
// fetchHash fetches dep's tree at commit, returns its content hash, and
// pre-populates the content store with it — so a later `graft apply` of the
-// same lockfile installs without any download (spec §5.6).
+// same lockfile installs without any download (spec §5.4).
func fetchHash(ctx context.Context, dep config.Dep, commit string) (string, error) {
cacheRoot, err := cachedir.Dir()
if err != nil {
diff --git a/cmd/graft/status.go b/cmd/graft/status.go
index 41b148f..39d2b68 100644
--- a/cmd/graft/status.go
+++ b/cmd/graft/status.go
@@ -123,7 +123,7 @@ func storeRoot() (string, error) {
}
// statusRows builds one [name, locked info, state] row per manifest dep,
-// lock-only dep, and extra vendor path (spec §4.4).
+// lock-only dep, and extra vendor path (spec §4.5).
func statusRows(p *project, lf *lockfile.Lockfile, lockFound bool) ([][3]string, error) {
sr, err := storeRoot()
if err != nil {
diff --git a/docs/design.md b/docs/design.md
index 204b15a..ed495c9 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,7 +1,7 @@
# Graft — Design Document
> Status: draft v0.9
-> Last updated: 2026-06-13
+> Last updated: 2026-07-03
>
> This is a translation of [`design.zh-TW.md`](design.zh-TW.md); the Chinese version is authoritative if the two ever disagree.
@@ -151,7 +151,7 @@ Normalization rules ensure the same file tree hashes identically on every platfo
| `lock` | no | yes | no | Re-sync the lockfile from `graft.toml`. New entries, and entries whose `repo` or `version` changed, are re-resolved and fetched to a temp directory to compute the content hash — but nothing is installed. Entries whose `repo` and `version` are both unchanged keep their locked commit (no network); when only `subdir` or `symlinks` changed, the locked commit is re-fetched to recompute `hash`, with no ref lookup |
| `lock --check` | no | no | no | Verify that `graft.lock` is already the up-to-date resolution of `graft.toml` **without writing any files**; consistent → exit 0, needs re-resolution → exit 2 with a list of out-of-date entries (§4.3) |
| `status` | no | no | no | Read-only report of the manifest ↔ lockfile ↔ vendor sync state |
-| `cache` | — | — | — | Inspect or prune the global cache (`dir`, `verify`, `prune`); never touches project files |
+| `cache` | — | — | — | Inspect or prune the global cache (`dir`, `verify`, `prune`, `clean`); never touches project files |
`graft init [dir]` creates `graft.toml` in the current directory. The optional argument sets the install root; it defaults to `"deps"` when omitted. It fails with exit code 2 when `graft.toml` already exists — it never silently overwrites.
@@ -306,12 +306,12 @@ for each dependency (parallel, N workers):
│ no │
▼ │
ensure is in the bare-repo cache │
- (incremental fetch; fallback in 5.5) │
+ (incremental fetch; fallback in 5.3) │
│ │
▼ │
check out to /tmp/, │
remove .git (with set, sparse │
- checkout limits the working tree: see 5.5) │
+ checkout limits the working tree: see 5.3) │
│ │
▼ │
compute the content hash │
diff --git a/docs/design.zh-TW.md b/docs/design.zh-TW.md
index 1a1c78b..1b9be8a 100644
--- a/docs/design.zh-TW.md
+++ b/docs/design.zh-TW.md
@@ -1,7 +1,7 @@
# Graft — 設計文件
> 狀態:草稿 v0.9
-> 最後更新:2026-06-13
+> 最後更新:2026-07-03
這份文件描述 graft 的設計與行為規範(§1–§9)。決策紀錄(為什麼這樣設計)與未解決的問題不在此文件的範圍。
@@ -101,7 +101,7 @@ hash = "sha256:a665a45920422f9d417e4867efdc4fb8..."
| 欄位 | 描述 |
|-------|-------------|
| `lock_version` | 格式版本,目前固定為 `1`。用於偵測破壞性變更。 |
-| `dir` | 安裝根目錄,從 `graft.toml` 複製而來。在頂層記錄一次,讓 `graft apply` 只依賴鎖定檔:每個依賴的安裝路徑是 `/`,這也是 `apply` 在移除多餘依賴時判斷哪些路徑屬於自己管理的依據。 |
+| `dir` | 安裝根目錄,從 `graft.toml` 複製而來。在頂層記錄一次,讓 `graft apply` 只依賴鎖定檔:每個依賴的安裝路徑是 `/`,這也是 `apply` 在移除多餘依賴時判斷哪些路徑屬於自己管理的依據。 |
| `name` | 對應 `graft.toml` 中的 `name`。與頂層 `dir` 一起完整決定安裝路徑(`/`)。 |
| `repo` | 儲存庫路徑或 URL,從 `graft.toml` 複製而來。 |
| `version` | 版本字串,從 `graft.toml` 原樣複製。是清單與鎖定檔之間的同步鍵,也讓 `status` 與 `apply` 能離線輸出可讀的訊息。 |
@@ -149,7 +149,7 @@ hash = "sha256:a665a45920422f9d417e4867efdc4fb8..."
| `lock` | 否 | 是 | 否 | 從 `graft.toml` 重新同步鎖定檔。新條目以及 `repo` 或 `version` 變更的條目會被重新解析並擷取到暫存目錄以計算內容雜湊——但不安裝任何東西。`repo` 與 `version` 都未變的條目保留已鎖定的 commit(不連網);僅 `subdir` 或 `symlinks` 變更時重新擷取已鎖定的 commit 以重算 `hash`,完全不查詢 ref |
| `lock --check` | 否 | 否 | 否 | 驗證 `graft.lock` 已是 `graft.toml` 的最新解析結果但**不寫任何檔案**;一致 → 結束碼 0,需要重新解析 → 結束碼 2 並列出待更新條目(§4.3) |
| `status` | 否 | 否 | 否 | 唯讀回報清單 ↔ 鎖定檔 ↔ vendor 的同步狀態 |
-| `cache` | — | — | — | 檢視或清理全域快取(`dir`、`verify`、`prune`);永遠不會動到專案檔案 |
+| `cache` | — | — | — | 檢視或清理全域快取(`dir`、`verify`、`prune`、`clean`);永遠不會動到專案檔案 |
`graft init [dir]` 在當前目錄建立 `graft.toml`。可選引數設定安裝根目錄;省略時預設為 `"deps"`。`graft.toml` 已存在時以結束碼 2 失敗——永遠不會默默覆寫。
@@ -304,12 +304,12 @@ graft apply
│ 否 │
▼ │
確保 已在裸儲存庫快取中 │
- (增量擷取;fallback 策略見 5.5) │
+ (增量擷取;fallback 策略見 5.3) │
│ │
▼ │
將 簽出至 /tmp/, │
刪除 .git(設定 時由稀疏 │
- 簽出限制工作樹範圍:見 5.5) │
+ 簽出限制工作樹範圍:見 5.3) │
│ │
▼ │
計算內容雜湊 │
@@ -375,7 +375,7 @@ graft apply
**Content store。** 一個 store 條目就是某個鎖定檔 `hash` 對應的完整安裝樹:先簽出到 `tmp/`、計算雜湊、驗證,再原子 rename 到定位,所有檔案設為唯讀。因為鍵*就是* `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 刻意只支援這兩種:
+**具現化。** store 條目如何成為 ``,由 `GRAFT_LINK_MODE` 環境變數選擇。這是機器本地的選擇,所有會具現化的命令(`apply`、`add`、`remove`)一視同仁地遵循它——沒有任何 per-command 旗標,也永遠不會記錄在 `graft.toml` 或 `graft.lock`。若只想單次覆寫,為單一命令設定即可(`GRAFT_LINK_MODE=symlink graft apply`)。兩個模式名稱(`copy`、`symlink`)對齊 uv 的 link 模式詞彙;graft 刻意只支援這兩種:
- **copy**(預設)— 檔案系統支援時使用 copy-on-write reflink(APFS、btrfs、XFS、ReFS),否則一般複製。可觀察行為與沒有快取的 graft 完全相同,包括提交 `vendor/` 的工作流程,且 `apply` 每次執行仍照常重新驗證 vendor 樹的雜湊;安裝時從 store 具現化的樹在放進 vendor 前也會重新雜湊——損壞的 store 條目以結束碼 4 失敗並被移除(下次執行時重新擷取),永遠不會被安裝。
- **symlink**(選擇性啟用:`GRAFT_LINK_MODE=symlink`)— `` 變成單一個指向 store 的目錄 symlink(Windows 上為 junction,不需要管理員權限),並登記到 `links/`。任意數量的專案共用同一份磁碟上的檔案樹。驗證簡化為低成本的連結目標比對:指向 `store/<鎖定雜湊>` 即為 `ok`,目標錯誤為 `modified`,連結懸空為 `missing`。限制:`vendor/` 必須加入 gitignore(提交一個連結對其他機器毫無意義),且 vendor 的完整性此時建立在 store 的不可變性上——檔案為唯讀,因此透過連結的意外編輯會立即失敗。同步時若發現 dest 以另一種模式具現化,視為偏移並以當前模式重寫。
diff --git a/docs/requirements.md b/docs/requirements.md
index eae970c..07287c8 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -53,14 +53,14 @@ incrementally; each addition forces a covering test.
| REQ-STATUS-STATES | `graft status` reports `ok` / `missing` / `modified` / `extra` / `out of sync` per dependency. | §4.5 |
| REQ-STATUS-EXIT | `graft status` exits 0 when everything is `ok`, 2 on a toml↔lock disagreement (`out of sync`), and 1 on pure vendor drift (`missing`/`modified`/`extra`); the more severe code wins when both occur. | §4.5, §4.6 |
| REQ-STATUS-MODE-DRIFT | `graft status` judges each dest by the current link mode: a dest materialized in the other mode (a symlinked dest in copy mode, a real tree in link mode) reports `modified` — exactly the drift `graft apply` would rewrite, so an all-`ok` status implies `apply` is a no-op under the same mode. | §4.5, §5.4 |
-| REQ-EXIT-NET | A remote that cannot be reached fails with exit code 3 (network error). | §4.6, §5.5 |
+| REQ-EXIT-NET | A remote that cannot be reached fails with exit code 3 (network error). | §4.6, §5.3 |
| REQ-INTEGRITY | A content-hash mismatch fails with exit code 4 (content integrity failure). | §4.6, §7 |
| REQ-STORE-INSTALL-VERIFY | In copy mode, the install path re-hashes the tree it materializes from the content store before it reaches the vendor directory; a mismatch installs nothing, fails with exit code 4, and removes the corrupted store entry so the next run re-fetches it. | §5.4, §7 |
| REQ-APPLY-STAGING-CLEANUP | Every reconcile removes the staging directory `/.graft-tmp` before returning — on failure as well as on success. | §5.1 |
| REQ-PATH-GITSEG | A `dir`, `name`, or `subdir` whose path contains a `.git` segment (e.g. `.git`, `.git/vendor`, `vendor/.git`) is rejected at load with exit code 2, so the destructive vendor reconcile can never overlap the git repository. | §7 |
| REQ-NAME-STAGING | A dependency whose `name`'s first segment starts with the reserved `.graft-` prefix (matched case-insensitively, e.g. `.graft-tmp`, `.GRAFT-TMP`, `.graft-cache`) is rejected at load with exit code 2, so its install path can never collide with the reconcile staging directory or other internal directories — including on case-insensitive filesystems. A `.`-bearing name like `github.com/org/repo`, or a bare `.graft` without the hyphen, is unaffected. | §7 |
-| REQ-PARALLEL-COLLECT | The parallel reconcile collects and reports every dependency's error, rather than failing fast on the first. | §5.4 |
-| REQ-JOBS-FETCHDEFAULT | The fetch phase runs up to 16 concurrent workers, which can exceed `runtime.NumCPU()`; the install phase runs up to `runtime.NumCPU()` workers. | §5.4 |
+| REQ-PARALLEL-COLLECT | The parallel reconcile collects and reports every dependency's error, rather than failing fast on the first. | §5.2 |
+| REQ-JOBS-FETCHDEFAULT | The fetch phase runs up to 16 concurrent workers, which can exceed `runtime.NumCPU()`; the install phase runs up to `runtime.NumCPU()` workers. | §5.2 |
| REQ-JOBS-SAMEREPO | Multiple deps sharing the same bare-repo cache entry are serialized by the per-repo advisory lock. | §5.4, §5.5 |
| REQ-HASH-EXECBIT | The executable bit (git mode 100755 vs 100644) is included in each file's hash input as a single exec byte, sourced from git-index metadata recorded at fetch (`.graft-execbits`) rather than the live filesystem mode; an upstream exec-bit change is detected as drift (exit 4) and `graft apply` re-installs, while a local `chmod` on a vendored file does not affect the hash. | §3.2 |
| REQ-HASH-CASECOLLIDE | A fetched tree containing two paths that are identical after Unicode case-folding is rejected with exit code 2. | §3.2 |
diff --git a/internal/cachedir/cachedir.go b/internal/cachedir/cachedir.go
index 58b9391..b8dca00 100644
--- a/internal/cachedir/cachedir.go
+++ b/internal/cachedir/cachedir.go
@@ -1,7 +1,7 @@
// Copyright 2026 The Graft Authors
// Package cachedir resolves graft's per-user global cache directory
-// (spec §5.6): the OS user cache convention, overridable with
+// (spec §5.4): the OS user cache convention, overridable with
// GRAFT_CACHE_DIR. The cache is purely a performance layer — deleting it is
// always safe.
package cachedir
@@ -18,7 +18,7 @@ import (
// EnvOverride is the environment variable that overrides the cache location.
const EnvOverride = "GRAFT_CACHE_DIR"
-// Subdirectory names under the cache root (spec §5.6). locks/ is created on
+// Subdirectory names under the cache root (spec §5.4). locks/ is created on
// demand by the projlock package; the other four are created by the helpers
// below.
const (
@@ -70,7 +70,7 @@ func writeTag(root string) error {
}
// Dir returns graft's cache directory without creating it: $GRAFT_CACHE_DIR
-// when set, otherwise the per-OS user cache location of spec §5.6 —
+// when set, otherwise the per-OS user cache location of spec §5.4 —
// ~/.cache/graft on Linux, ~/Library/Caches/graft on macOS, and
// %LocalAppData%\graft\cache on Windows.
func Dir() (string, error) {
@@ -146,7 +146,7 @@ func Links() (string, error) { return ensureSubdir(LinksSubdir) }
func Tmp() (string, error) { return ensureSubdir(TmpSubdir) }
// ensureSubdir resolves /name and lazily creates it. The cache is a
-// pure performance layer (spec §5.6), so callers create only what they touch.
+// pure performance layer (spec §5.4), so callers create only what they touch.
func ensureSubdir(name string) (string, error) {
base, err := Dir()
if err != nil {
diff --git a/internal/clierr/clierr.go b/internal/clierr/clierr.go
index 654e4a7..5286c37 100644
--- a/internal/clierr/clierr.go
+++ b/internal/clierr/clierr.go
@@ -1,6 +1,6 @@
// Copyright 2026 The Graft Authors
-// Package clierr defines graft's process exit codes (spec §4.5) and the shared
+// Package clierr defines graft's process exit codes (spec §4.6) and the shared
// error type every command uses to report failures on stderr in the Cargo-style
// format of spec §6: what went wrong, followed by what to do next.
package clierr
@@ -10,7 +10,7 @@ import (
"strings"
)
-// Code is a process exit code as defined in spec §4.5.
+// Code is a process exit code as defined in spec §4.6.
type Code int
const (
@@ -65,7 +65,7 @@ func (e *Error) Error() string {
// Empty detail paragraphs are skipped.
//
// An error joined from several errors (errors.Join — e.g. a parallel
-// reconcile that collected one failure per dep, spec §5.4) renders each
+// reconcile that collected one failure per dep, spec §5.2) renders each
// joined error as its own block, separated by blank lines.
func Format(err error) string {
if joined, ok := err.(interface{ Unwrap() []error }); ok {
diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go
index 99e8202..38726b0 100644
--- a/internal/fetcher/fetcher.go
+++ b/internal/fetcher/fetcher.go
@@ -2,7 +2,7 @@
// Package fetcher fetches a locked commit from a remote repository and
// materializes its checked-out tree — without the .git directory — for
-// hashing and installation (spec §5.5, §3.2 normalization).
+// hashing and installation (spec §5.3, §3.2 normalization).
package fetcher
import (
@@ -27,7 +27,7 @@ import (
// becomes dst. The returned time is the committer timestamp of commit.
//
// The commit is fetched into repo's shared bare cache under cacheRoot (spec
-// §5.6) — incrementally, with the three-step fallback of §5.5 — then checked
+// §5.4) — incrementally, with the three-step fallback of §5.3 — then checked
// out locally. version, when a tag, enables the middle fallback step. name is
// the dependency name, used in error messages. The checkout forces
// core.autocrlf=false and core.eol=lf and never writes a .git directory, so
@@ -104,7 +104,7 @@ func Fetch(ctx context.Context, cacheRoot, name, repo, commit, version, path, ds
// rejectLFS fails with exit 2 when any .gitattributes in the tree declares
// the lfs filter: a plain git checkout would pin pointer files instead of
-// content (spec §5.5).
+// content (spec §5.3).
func rejectLFS(name, root string) error {
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
diff --git a/internal/gitrun/gitrun.go b/internal/gitrun/gitrun.go
index 174e317..14aa360 100644
--- a/internal/gitrun/gitrun.go
+++ b/internal/gitrun/gitrun.go
@@ -2,7 +2,7 @@
// Package gitrun runs the system git for the resolver and fetcher packages:
// command execution, repo-path-to-URL mapping, the shared fetch helpers, and
-// network error classification (spec §4.5, §5.5).
+// network error classification (spec §4.6, §5.3).
package gitrun
import (
@@ -39,7 +39,7 @@ func RemoteURL(repo string) string {
// // form (spec §10.8), with any ".git" suffix and trailing
// slash removed. HTTPS, scheme-less, and scp-like SSH spellings of the same
// remote all collapse to the same string, so it is the right key both for the
-// bare-repo cache (§5.6) and for matching `graft add` arguments against
+// bare-repo cache (§5.4) and for matching `graft add` arguments against
// existing manifest entries. It is never used as the stored manifest value.
func CanonicalRepo(repo string) string {
repo = strings.TrimSuffix(strings.TrimSpace(repo), "/")
@@ -126,7 +126,7 @@ func Reachable(ctx context.Context, repo string) bool {
// FetchSHA fetches a single commit at depth 1 into the repository at dir —
// the cheap path, which the server may reject for unadvertised SHAs
-// (spec §5.5).
+// (spec §5.3).
func FetchSHA(ctx context.Context, dir, repo, sha string) error {
_, err := Run(ctx, dir, "fetch", "--quiet", "--depth=1", "--", RemoteURL(repo), sha)
@@ -134,7 +134,7 @@ func FetchSHA(ctx context.Context, dir, repo, sha string) error {
}
// FetchAll fetches every branch and tag from the remote into the repository
-// at dir — the always-correct fallback of spec §5.5. A failure is classified
+// at dir — the always-correct fallback of spec §5.3. A failure is classified
// as a network error when the remote is unreachable.
func FetchAll(ctx context.Context, dir, repo string) error {
// Branches land under refs/remotes so the fetch can never collide with a
diff --git a/internal/gitrun/gitrun_test.go b/internal/gitrun/gitrun_test.go
index 455e52d..706d944 100644
--- a/internal/gitrun/gitrun_test.go
+++ b/internal/gitrun/gitrun_test.go
@@ -128,7 +128,7 @@ func TestRunEnv_setsExtraEnv(t *testing.T) {
}
}
-// TestNetworkErr verifies the wrapped error carries the spec §4.5 network
+// TestNetworkErr verifies the wrapped error carries the spec §4.6 network
// exit code and mentions the repo and the underlying reason.
func TestNetworkErr(t *testing.T) {
t.Parallel()
@@ -221,7 +221,7 @@ func TestFetchAll(t *testing.T) {
}
// TestFetchAll_networkError verifies an unreachable remote is classified as
-// the spec §4.5 exit-3 network error, not a generic failure.
+// the spec §4.6 exit-3 network error, not a generic failure.
func TestFetchAll_networkError(t *testing.T) {
t.Parallel()
diff --git a/internal/links/links.go b/internal/links/links.go
index 43a6e0a..1c19e87 100644
--- a/internal/links/links.go
+++ b/internal/links/links.go
@@ -1,6 +1,6 @@
// Copyright 2026 The Graft Authors
-// Package links is the registry of link-mode vendor dests (spec §5.6): one
+// Package links is the registry of link-mode vendor dests (spec §5.4): one
// small file per dest under /links, recording which store entry the
// dest's symlink points at. `graft cache prune` reads it to know which store
// entries are still referenced; link-mode `graft apply` writes it. The
@@ -41,7 +41,7 @@ func Register(linksDir, destAbs, hash string) error {
// dest points at, so clean can keep exactly those entries. A registration
// whose dest no longer links to its recorded hash — the dest was removed, or
// rewritten to a copy-mode tree — is stale: it is pruned and does not keep its
-// store entry alive, so clean can actually reclaim it (spec §5.6). A missing
+// store entry alive, so clean can actually reclaim it (spec §5.4). A missing
// links directory yields an empty set.
func ReferencedHashes(linksDir string) (map[string]bool, error) {
entries, err := os.ReadDir(linksDir)
diff --git a/internal/projlock/projlock.go b/internal/projlock/projlock.go
index ab66909..6f089ec 100644
--- a/internal/projlock/projlock.go
+++ b/internal/projlock/projlock.go
@@ -1,7 +1,7 @@
// Copyright 2026 The Graft Authors
// Package projlock serializes mutating graft commands on the same project
-// (spec §5.7): an exclusive advisory file lock in the global cache, keyed by
+// (spec §5.5): an exclusive advisory file lock in the global cache, keyed by
// the project root. A second graft process blocks until the first finishes,
// printing a hint when the wait exceeds a second — the cargo/uv behavior.
package projlock
@@ -36,7 +36,7 @@ const (
// are consistent again.
//
// The lock file lives in the global cache — never in the repository — at
-// locks/projects/ (spec §5.7).
+// locks/projects/ (spec §5.5).
func Acquire(ctx context.Context, root string, warn io.Writer) (release func(), err error) {
path, err := lockPath(root)
if err != nil {
diff --git a/internal/repocache/repocache.go b/internal/repocache/repocache.go
index c28d592..544e84b 100644
--- a/internal/repocache/repocache.go
+++ b/internal/repocache/repocache.go
@@ -1,6 +1,6 @@
// Copyright 2026 The Graft Authors
-// Package repocache manages graft's shared bare-repository cache (spec §5.6):
+// Package repocache manages graft's shared bare-repository cache (spec §5.4):
// every fetch targets repos///.git, keyed by the canonical
// repo form so HTTPS and SSH spellings share one entry. Fetches are
// incremental — a commit already in the cache is never re-fetched — and
@@ -31,7 +31,7 @@ const lockPollInterval = 50 * time.Millisecond
// BarePath returns the bare-repository path for repo under cacheRoot:
// /repos///.git, keyed by the canonical repo form
-// (spec §5.6, §10.8) so every spelling of the same remote shares one entry.
+// (spec §5.4, §10.8) so every spelling of the same remote shares one entry.
func BarePath(cacheRoot, repo string) string {
canonical := gitrun.CanonicalRepo(repo)
@@ -41,7 +41,7 @@ func BarePath(cacheRoot, repo string) string {
// EnsureCommit makes commit available in repo's bare cache and returns the
// bare-repository path. It takes the per-repo advisory fetch lock, skips the
// fetch when the commit is already cached (incremental), and otherwise runs
-// the three-step fallback of spec §5.5. version, when a tag, enables the
+// the three-step fallback of spec §5.3. version, when a tag, enables the
// middle step. For a path-scoped dep the fetch uses --filter=blob:none, so the
// bare repo becomes a partial clone and blobs outside the path are fetched
// lazily at checkout time.
@@ -58,7 +58,7 @@ func EnsureCommit(ctx context.Context, cacheRoot, repo, commit, version, path st
return "", err
}
- // Incremental: never re-fetch a commit already in the cache (spec §5.5).
+ // Incremental: never re-fetch a commit already in the cache (spec §5.3).
if hasCommit(ctx, bare, commit) {
return bare, nil
}
@@ -120,7 +120,7 @@ func CommitTime(ctx context.Context, bare, commit string) (time.Time, error) {
return t.UTC(), nil
}
-// fetchCommit runs the three-step fallback fetch of spec §5.5 into bare. A
+// fetchCommit runs the three-step fallback fetch of spec §5.3 into bare. A
// path dep filters out blobs (--filter=blob:none); the commit, its trees, and
// the path's blobs are all that ever land in the cache.
func fetchCommit(ctx context.Context, bare, repo, commit, version, path string) error {
@@ -165,7 +165,7 @@ func fetchRef(ctx context.Context, bare, ref string, filtered bool) error {
// fetchAll fetches every branch and tag from origin into bare, mapping the
// refs into refs/remotes and refs/tags so nothing collides. When filtered, it
// first tries a blob-filtered fetch and silently retries unfiltered if the
-// server does not support partial clone (spec §5.5) — the checkout then still
+// server does not support partial clone (spec §5.3) — the checkout then still
// restricts the working tree to the path. An unreachable remote is reported as
// a network error (exit 3).
func fetchAll(ctx context.Context, bare, repo string, filtered bool) error {
@@ -328,7 +328,7 @@ func ExecBits(ctx context.Context, bare, commit, subPath string) (map[string]boo
// Clean removes every cached bare repository last fetched before the given
// time, returning the removed repos' cache-relative paths in sorted order
-// (spec §4.6). Removing a bare repo only costs a re-fetch; deleting the whole
+// (spec §4.7). Removing a bare repo only costs a re-fetch; deleting the whole
// cache is always safe.
func Clean(cacheRoot string, before time.Time) (removed []string, freed int64, err error) {
reposDir := filepath.Join(cacheRoot, cachedir.ReposSubdir)
@@ -405,7 +405,7 @@ func PathExists(ctx context.Context, bare, commit, path string) (bool, error) {
}
// lock takes the exclusive advisory fetch lock for repo, serializing
-// concurrent fetches of the same bare repository (spec §5.6). The lock file
+// concurrent fetches of the same bare repository (spec §5.4). The lock file
// lives in /locks/repos, keyed by the canonical repo hash.
func lock(ctx context.Context, cacheRoot, repo string) (func(), error) {
dir := filepath.Join(cacheRoot, "locks", "repos")
diff --git a/internal/repocache/repocache_internal_test.go b/internal/repocache/repocache_internal_test.go
index 43c1d4b..b1b794f 100644
--- a/internal/repocache/repocache_internal_test.go
+++ b/internal/repocache/repocache_internal_test.go
@@ -13,7 +13,7 @@ import (
)
// TestFetchRef_rejectsOptionLikeRef guards fetchRef against option
-// injection: the middle fallback step of fetchCommit (spec §5.5) passes the
+// injection: the middle fallback step of fetchCommit (spec §5.3) passes the
// manifest/lockfile "version" string to fetchRef verbatim, and that string
// is not validated against git's flag syntax. A ref shaped like a git option
// (e.g. "--upload-pack=...") must be rejected as a literal, unmatched ref —
@@ -41,7 +41,7 @@ func TestFetchRef_rejectsOptionLikeRef(t *testing.T) {
}
// TestIsTag checks that an empty version and a pseudo-version are excluded,
-// while any other non-empty string counts as a tag (spec §5.5's middle
+// while any other non-empty string counts as a tag (spec §5.3's middle
// fallback step only applies to real tags).
func TestIsTag(t *testing.T) {
t.Parallel()
@@ -107,7 +107,7 @@ func TestCommitGoneErr(t *testing.T) {
}
}
-// TestFetchAllRefs verifies the always-correct fallback (spec §5.5 step 3)
+// TestFetchAllRefs verifies the always-correct fallback (spec §5.3 step 3)
// fetches every branch and tag from origin into the bare repo.
func TestFetchAllRefs(t *testing.T) {
t.Parallel()
@@ -145,7 +145,7 @@ func TestFetchAllRefs(t *testing.T) {
}
// TestFetchAll_unreachable verifies that fetchAll classifies a failure
-// against an unreachable remote as the spec §4.5 exit-3 network error.
+// against an unreachable remote as the spec §4.6 exit-3 network error.
func TestFetchAll_unreachable(t *testing.T) {
t.Parallel()
diff --git a/internal/repocache/repocache_test.go b/internal/repocache/repocache_test.go
index 23f5dc0..755152c 100644
--- a/internal/repocache/repocache_test.go
+++ b/internal/repocache/repocache_test.go
@@ -15,7 +15,7 @@ import (
// TestBarePath_canonicalKey checks that every spelling of one remote — HTTPS,
// scheme-less, .git-suffixed, and scp-like SSH — maps to the same bare-repo
-// path, so they all share one cache entry (spec §5.6, §10.8).
+// path, so they all share one cache entry (spec §5.4, §10.8).
func TestBarePath_canonicalKey(t *testing.T) {
t.Parallel()
@@ -38,7 +38,7 @@ func TestBarePath_canonicalKey(t *testing.T) {
// TestEnsureCommit_incrementalOffline verifies that a commit already in the
// cache is never re-fetched: after the first fetch the remote is removed, and
-// a second EnsureCommit of the same commit still succeeds (spec §5.5).
+// a second EnsureCommit of the same commit still succeeds (spec §5.3).
func TestEnsureCommit_incrementalOffline(t *testing.T) {
t.Parallel()
@@ -101,7 +101,7 @@ func TestEnsureCommit_tagFallback(t *testing.T) {
// TestEnsureCommit_allRefsFallback covers the final fallback step: a non-tip
// commit with no tag (isTag false) skips the middle step entirely and must
-// still be found by fetching every ref (spec §5.5 step 3).
+// still be found by fetching every ref (spec §5.3 step 3).
func TestEnsureCommit_allRefsFallback(t *testing.T) {
t.Parallel()
diff --git a/internal/store/reflink_linux.go b/internal/store/reflink_linux.go
index ff3fed0..7794dd4 100644
--- a/internal/store/reflink_linux.go
+++ b/internal/store/reflink_linux.go
@@ -12,7 +12,7 @@ import (
)
// reflink creates dst as a copy-on-write clone of src via the FICLONE ioctl,
-// supported on btrfs, XFS, and other CoW filesystems (spec §5.6). It returns
+// supported on btrfs, XFS, and other CoW filesystems (spec §5.4). It returns
// an error — leaving no partial dst — when the filesystem does not support
// cloning, so the caller falls back to a plain copy.
func reflink(src, dst string, mode fs.FileMode) error {
diff --git a/internal/store/reflink_other.go b/internal/store/reflink_other.go
index ff66ce3..b23bc99 100644
--- a/internal/store/reflink_other.go
+++ b/internal/store/reflink_other.go
@@ -14,7 +14,7 @@ import (
var errReflinkUnsupported = errors.New("reflink not supported on this platform")
// reflink always fails on non-Linux platforms so copyOrReflink falls back to a
-// plain copy. ReFS and APFS clones are a future optimization (spec §5.6).
+// plain copy. ReFS and APFS clones are a future optimization (spec §5.4).
func reflink(_, _ string, _ fs.FileMode) error {
return errReflinkUnsupported
}
diff --git a/internal/store/store.go b/internal/store/store.go
index 03d6434..c248afa 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -45,7 +45,7 @@ func Exists(storeRoot, hash string) bool {
// filesystem as storeRoot (both under the cache), so the move is an atomic
// rename. The entry is made read-only before it is published. If another
// process created the same entry first, src is discarded and the existing
-// entry is used (spec §5.3 rename race).
+// entry is used (spec §5.1 rename race).
func Insert(storeRoot, src, hash string) (string, error) {
dst := Path(storeRoot, hash)
@@ -78,7 +78,7 @@ func Insert(storeRoot, src, hash string) (string, error) {
// Materialize copies the store entry at storePath into dst, which must not
// exist yet, using copy-on-write reflinks where the filesystem supports them
-// (spec §5.6) and plain copies otherwise. dst files are writable — copy mode
+// (spec §5.4) and plain copies otherwise. dst files are writable — copy mode
// behaves exactly like graft without a cache, including the commit-vendor
// workflow.
func Materialize(storePath, dst string) error {
@@ -150,7 +150,7 @@ func copyFile(src, dst string, mode fs.FileMode) error {
// makeReadOnly strips write permission from every file in tree, so a published
// store entry cannot be mutated in place — including through a link-mode dest,
-// where an accidental edit then fails immediately (spec §5.6). Directories stay
+// where an accidental edit then fails immediately (spec §5.4). Directories stay
// writable so the entry can still be traversed and, by `cache prune`, removed.
func makeReadOnly(tree string) error {
return filepath.WalkDir(tree, func(p string, d fs.DirEntry, err error) error {
@@ -184,7 +184,7 @@ func discard(tree string) error {
// Verify re-hashes every store entry and deletes any whose content no longer
// matches its key, returning how many entries it checked and the deleted
-// entries' hashes in sorted order (spec §4.6). A missing store reports zero.
+// entries' hashes in sorted order (spec §4.7). A missing store reports zero.
func Verify(storeRoot string) (checked int, corrupted []string, err error) {
err = eachEntry(storeRoot, func(hash, path string) error {
checked++
@@ -213,7 +213,7 @@ func Verify(storeRoot string) (checked int, corrupted []string, err error) {
// Clean removes store entries that are neither referenced nor recent: a hash
// not in keep AND last modified before `before`. It returns the removed hashes
-// in sorted order plus their total logical size (spec §4.6). Pass the hashes
+// in sorted order plus their total logical size (spec §4.7). Pass the hashes
// still referenced by registered link-mode dests; copy-mode vendors reference
// nothing, so only the age floor protects them.
//
diff --git a/internal/vendordir/linkdir_other.go b/internal/vendordir/linkdir_other.go
index cdce4d3..d3bfe62 100644
--- a/internal/vendordir/linkdir_other.go
+++ b/internal/vendordir/linkdir_other.go
@@ -6,7 +6,7 @@ package vendordir
import "os"
-// linkDir creates a directory symlink at link pointing at target (spec §5.6).
+// linkDir creates a directory symlink at link pointing at target (spec §5.4).
// The target is absolute (a store entry), so the link resolves the same from
// any vendor location.
func linkDir(target, link string) error {
diff --git a/internal/vendordir/linkdir_windows.go b/internal/vendordir/linkdir_windows.go
index 7b975fd..fa696e7 100644
--- a/internal/vendordir/linkdir_windows.go
+++ b/internal/vendordir/linkdir_windows.go
@@ -12,7 +12,7 @@ import (
"golang.org/x/sys/windows"
)
-// linkDir creates a directory junction at link pointing at target (spec §5.6).
+// linkDir creates a directory junction at link pointing at target (spec §5.4).
// A junction — not a symlink — is used because it needs no administrator
// privilege. The reparse point is set via DeviceIoControl (FSCTL_SET_REPARSE_POINT)
// to avoid spawning cmd.exe. target must be an absolute path.
diff --git a/internal/vendordir/vendordir.go b/internal/vendordir/vendordir.go
index be042dc..f9d07e8 100644
--- a/internal/vendordir/vendordir.go
+++ b/internal/vendordir/vendordir.go
@@ -1,7 +1,7 @@
// Copyright 2026 The Graft Authors
// Package vendordir reconciles the on-disk vendor state with the lockfile
-// (spec §5.3): missing deps are installed, extras removed, and mismatched
+// (spec §5.1): missing deps are installed, extras removed, and mismatched
// trees replaced, with every install staged in /.graft-tmp and moved
// into place by an atomic same-filesystem rename.
package vendordir
@@ -34,7 +34,7 @@ const (
// start of every reconcile.
StagingDirName = ".graft-tmp"
// defaultFetchJobs is the default number of concurrent workers in the fetch
- // phase (spec §5.4).
+ // phase (spec §5.2).
defaultFetchJobs = 16
)
@@ -42,7 +42,7 @@ const (
// but whose parent directory does.
type FetchFunc func(ctx context.Context, dep lockfile.LockedDep, dst string) error
-// Mode is how a store entry becomes a dest (spec §5.6). It is a machine-local
+// Mode is how a store entry becomes a dest (spec §5.4). It is a machine-local
// choice, never recorded in graft.toml or graft.lock.
type Mode int
@@ -113,7 +113,7 @@ func Reconcile(
)
}
- // Clean staging left behind by an interrupted run (spec §5.3).
+ // Clean staging left behind by an interrupted run (spec §5.1).
if err := gitrun.RemoveAll(staging); err != nil {
return nil, fmt.Errorf("clean stale staging: %w", err)
}
@@ -164,7 +164,7 @@ type fetchResult struct {
err error
}
-// fetchDep is the fetch-phase worker for one dep (spec §5.4). It checks
+// fetchDep is the fetch-phase worker for one dep (spec §5.2). It checks
// whether the dest is already up-to-date and returns skip=true if so;
// otherwise it ensures the dep's tree is present in the content store.
func fetchDep(
@@ -202,7 +202,7 @@ func fetchDep(
return fetchResult{storePath: sp, err: err}
}
-// installDep is the install-phase worker for one dep (spec §5.4). It
+// installDep is the install-phase worker for one dep (spec §5.2). It
// materializes the already-stored tree at storePath into destAbs.
func installDep(staging, destAbs string, dep lockfile.LockedDep, seq int, opts Options, storePath string) error {
if opts.Mode == ModeLink {
@@ -230,7 +230,7 @@ func installDep(staging, destAbs string, dep lockfile.LockedDep, seq int, opts O
return nil
}
-// reconcileDeps runs the two-phase reconcile for every dep (spec §5.4).
+// reconcileDeps runs the two-phase reconcile for every dep (spec §5.2).
//
// Phase 1 — fetch (high concurrency, network-bound): each worker calls
// fetchDep, which checks whether the dest is already current, and on a miss
@@ -241,7 +241,7 @@ func installDep(staging, destAbs string, dep lockfile.LockedDep, seq int, opts O
// installDep, which moves the prepared store tree into the vendor directory.
//
// Errors from both phases are collected — not fail-fast — and joined in
-// lockfile order, so one run surfaces every failure at once (spec §5.4).
+// lockfile order, so one run surfaces every failure at once (spec §5.2).
func reconcileDeps(
ctx context.Context,
root, vendorDir, staging string,
@@ -327,7 +327,7 @@ func reconcileDeps(
}
// LinkMatches reports whether destAbs is a symlink (or Windows junction)
-// pointing at storePath, the cheap link-mode validation of spec §5.6.
+// pointing at storePath, the cheap link-mode validation of spec §5.4.
func LinkMatches(destAbs, storePath string) bool {
target, err := os.Readlink(destAbs)
if err != nil {
@@ -347,7 +347,7 @@ func cleanLinkTarget(target string) string {
// ensureStored returns the store path for dep's locked content, fetching and
// verifying it on a store miss. A fetched tree whose hash does not match the
-// lockfile is an exit-4 integrity failure (spec §5.3).
+// lockfile is an exit-4 integrity failure (spec §5.1).
func ensureStored(ctx context.Context, dep lockfile.LockedDep, opts Options, skipSymlinks bool) (string, error) {
if store.Exists(opts.StoreRoot, dep.Hash) {
return store.Path(opts.StoreRoot, dep.Hash), nil
@@ -476,7 +476,7 @@ func install(staging, storePath, destAbs string, dep lockfile.LockedDep, seq int
// FindExtras returns the paths under vendorDir that no locked dep owns —
// excluding the staging directory and the locked dests themselves — relative to
// the project root and slash-separated. It is the single source of truth for
-// "what is an extra" (spec §5.3), shared by the destructive reconcile
+// "what is an extra" (spec §5.1), shared by the destructive reconcile
// (removeExtras) and the read-only status command.
func FindExtras(root, vendorDir string, deps []lockfile.LockedDep) ([]string, error) {
vendorAbs := filepath.Join(root, filepath.FromSlash(vendorDir))
@@ -509,7 +509,7 @@ func FindExtras(root, vendorDir string, deps []lockfile.LockedDep) ([]string, er
switch {
case rel == vendorDir && e.Name() == StagingDirName:
- // Never an extra (spec §5.3).
+ // Never an extra (spec §5.1).
case owned[eRel]:
// A locked dest owns this subtree.
case e.IsDir() && ownsBelow(eRel, owned):
diff --git a/internal/vendordir/vendordir_test.go b/internal/vendordir/vendordir_test.go
index 6d8ea70..3176412 100644
--- a/internal/vendordir/vendordir_test.go
+++ b/internal/vendordir/vendordir_test.go
@@ -536,7 +536,7 @@ func TestReconcile_defaultFetchConcurrency(t *testing.T) {
}
}
-// TestLinkMatches verifies the cheap link-mode validation of spec §5.6: true
+// TestLinkMatches verifies the cheap link-mode validation of spec §5.4: true
// only when dest is a symlink pointing at storePath, false for a mismatched
// target, a plain file, or a missing dest.
func TestLinkMatches(t *testing.T) {
diff --git a/script/install.ps1 b/script/install.ps1
index dae9f9e..41109e9 100644
--- a/script/install.ps1
+++ b/script/install.ps1
@@ -9,7 +9,7 @@
.EXAMPLE
irm https://raw.githubusercontent.com/min0625/graft/main/script/install.ps1 | iex
.EXAMPLE
- $env:GRAFT_VERSION = 'v1.0.0'
+ $env:GRAFT_VERSION = 'v0.0.1-beta.1'
irm https://raw.githubusercontent.com/min0625/graft/main/script/install.ps1 | iex
#>
diff --git a/script/install.sh b/script/install.sh
index 45254fd..8dc80e7 100644
--- a/script/install.sh
+++ b/script/install.sh
@@ -133,7 +133,7 @@ main() {
os="$(detect_os)"
arch="$(detect_arch)"
- # allow pinning version via env: GRAFT_VERSION=v1.2.3
+ # allow pinning version via env: GRAFT_VERSION=v0.0.1-beta.1
version="${GRAFT_VERSION:-}"
if [[ -z "${version}" ]]; then
info "Fetching latest version..."