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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<AREA>-<TOKEN>` 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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ $ 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)**

```powershell
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

Expand Down
34 changes: 17 additions & 17 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ $ 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)**

```powershell
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'` 釘選版本。

### 手動下載

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` 指定要更新哪一個。

---

Expand Down Expand Up @@ -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` 會重寫的偏移。
Expand Down Expand Up @@ -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)。

---

Expand All @@ -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)——檔案格式、命令語義、結束碼、架構、安全考慮與測試策略。

---

Expand Down
4 changes: 2 additions & 2 deletions cmd/graft/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions cmd/graft/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/graft/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/graft/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion cmd/graft/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmd/graft/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading