From 46379ffa205710866cbbcbd9185562ed42318ec6 Mon Sep 17 00:00:00 2001 From: Min Huang <70873102+min0625@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:16:36 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20fix=20stale=20spec=20section=20refe?= =?UTF-8?q?rences=20after=20=C2=A74/=C2=A75=20renumbering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design docs gained §4.3 (lock --check) and a restructured §5, shifting apply→§4.4, status→§4.5, exit codes→§4.6, cache commands→§4.7, and parallelism/fetch/cache/locking→§5.2–§5.5. Many references still used the old numbering. This aligns everything with the current docs: - Go comments (source + tests): §4.5→§4.6 exit codes, §4.6→§4.7 cache commands, §5.3→§5.1 apply flow, §5.4→§5.2 parallelism, §5.5→§5.3 fetch strategy, §5.6→§5.4 cache/store, §5.7→§5.5 project lock; checkSync's comment now lists the fields it actually compares (subdir, symlinks) - design.zh-TW.md / design.md: §5.1 diagram cross-references (5.5→5.3), §4.1 cache row now lists `clean`, refreshed the last-updated date - requirements.md: REQ-EXIT-NET §5.5→§5.3, REQ-PARALLEL-COLLECT and REQ-JOBS-FETCHDEFAULT §5.4→§5.2 - CLAUDE.md: §4.5→§4.6, Milestone 5 status (README already has CI examples), `make test` no longer claims -v - READMEs: GRAFT_VERSION example uses a real released tag instead of v1.0.0; zh-TW README links the English design doc; normalized stray half-width punctuation in zh-TW prose Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 ++--- README.md | 4 +-- README.zh-TW.md | 34 +++++++++---------- cmd/graft/apply.go | 4 +-- cmd/graft/apply_test.go | 8 ++--- cmd/graft/cache_test.go | 2 +- cmd/graft/cli_test.go | 2 +- cmd/graft/golden_test.go | 2 +- cmd/graft/link_test.go | 4 +-- cmd/graft/project.go | 6 ++-- cmd/graft/projlock_test.go | 4 +-- cmd/graft/relock.go | 2 +- cmd/graft/status.go | 2 +- docs/design.md | 8 ++--- docs/design.zh-TW.md | 12 +++---- docs/requirements.md | 6 ++-- internal/cachedir/cachedir.go | 8 ++--- internal/clierr/clierr.go | 6 ++-- internal/fetcher/fetcher.go | 6 ++-- internal/gitrun/gitrun.go | 8 ++--- internal/gitrun/gitrun_test.go | 4 +-- internal/links/links.go | 4 +-- internal/projlock/projlock.go | 4 +-- internal/repocache/repocache.go | 16 ++++----- internal/repocache/repocache_internal_test.go | 8 ++--- internal/repocache/repocache_test.go | 6 ++-- internal/store/reflink_linux.go | 2 +- internal/store/reflink_other.go | 2 +- internal/store/store.go | 10 +++--- internal/vendordir/linkdir_other.go | 2 +- internal/vendordir/linkdir_windows.go | 2 +- internal/vendordir/vendordir.go | 24 ++++++------- internal/vendordir/vendordir_test.go | 2 +- 33 files changed, 111 insertions(+), 111 deletions(-) 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) { From 491fb599167d5a1628894bf4a7a3bae8b8e76c6d Mon Sep 17 00:00:00 2001 From: Min Huang <70873102+min0625@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:17:23 +0000 Subject: [PATCH 2/2] docs: use a real released tag in install-script version examples Co-Authored-By: Claude Fable 5 --- script/install.ps1 | 2 +- script/install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..."