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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ The state in the last column is one of:
| `extra` | in the vendor directory, absent from the lockfile |
| `out of sync` | `graft.toml` and `graft.lock` disagree (run `graft lock`) |

Exits 0 when everything is in sync, 1 on vendor-directory drift (missing/modified/extra), and 2 when `graft.toml` and `graft.lock` disagree (the same lockfile-out-of-sync code as `graft lock --check` and `graft apply`; the higher code wins when both occur) — handy as a CI guard against hand-edited vendored files. With no dependencies it prints `✓ no dependencies`. For link-mode dests the check is a cheap link-target comparison (the store is immutable; use `graft cache verify` to re-hash store entries).
Exits 0 when everything is in sync, 1 on vendor-directory drift (missing/modified/extra), and 2 when `graft.toml` and `graft.lock` disagree (the same lockfile-out-of-sync code as `graft lock --check` and `graft apply`; the higher code wins when both occur) — handy as a CI guard against hand-edited vendored files. With no dependencies it prints `✓ no dependencies`. For link-mode dests the check is a cheap link-target comparison (the store is immutable; use `graft cache verify` to re-hash store entries). A dest materialized in the other mode — a symlinked dest in copy mode, a real tree in link mode — reports `modified`: exactly what `graft apply` would rewrite.

---

Expand Down
2 changes: 1 addition & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ $ graft status
| `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`)。
全部同步時以結束碼 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
38 changes: 38 additions & 0 deletions cmd/graft/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ func TestApply_modeSwitchRewrites(t *testing.T) {
}
}

// TestStatus_modeDriftReportsModified: status judges a dest by the current
// mode — one materialized in the other mode is exactly the drift apply would
// rewrite, so it must not report ok (e.g. as a CI guard it would otherwise
// wave through a committed link-mode leftover).
func TestStatus_modeDriftReportsModified(t *testing.T) {
// spec: REQ-STATUS-MODE-DRIFT
f := newFixtureRemote(t)
dir := newProjectDir(t)
writeProjectFile(t, dir, "graft.toml", manifestFor(f, tagV1))
mustRunGraft(t, "lock")

// Installed as a link, checked in copy mode → modified.
t.Setenv("GRAFT_LINK_MODE", "symlink")
mustRunGraft(t, "apply")
t.Setenv("GRAFT_LINK_MODE", "copy")

out, _, exitCode := runGraftStreams(t, "status")
if exitCode != 1 || !strings.Contains(out, "modified") {
t.Errorf("copy-mode status on a link dest = %q (exit %d), want modified (exit 1)", out, exitCode)
}

// Installed as a copy, checked in link mode → modified.
mustRunGraft(t, "apply") // rewrites the dest as a real tree
t.Setenv("GRAFT_LINK_MODE", "symlink")

out, _, exitCode = runGraftStreams(t, "status")
if exitCode != 1 || !strings.Contains(out, "modified") {
t.Errorf("link-mode status on a copy dest = %q (exit %d), want modified (exit 1)", out, exitCode)
}

// Re-applying in the current mode clears the drift.
mustRunGraft(t, "apply")

if out := mustRunGraft(t, "status"); !strings.Contains(out, "✓ scripts") {
t.Errorf("status after re-apply = %q, want ok", out)
}
}

// TestApply_linkRestoredAfterCacheWipe covers the clean→missing→re-apply loop
// of spec §4.6: a removed store entry leaves the link dangling (status
// missing), and apply re-materializes it. The cache is always safe to delete,
Expand Down
26 changes: 21 additions & 5 deletions cmd/graft/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ func newStatusCmd() *cobra.Command {
across graft.toml, graft.lock, and the vendor directory.

For link-mode dests, the check is a cheap link-target comparison (the store is
immutable; use 'graft cache verify' to re-hash store entries).
immutable; use 'graft cache verify' to re-hash store entries). A dest
materialized in the other mode — a symlinked dest in copy mode, a real tree in
link mode — reports "modified": that is exactly what 'graft apply' would
rewrite.

Exit codes: 0 when everything is in sync; 1 on vendor-directory drift
(missing/modified/extra); 2 when graft.toml and graft.lock disagree (same
Expand Down Expand Up @@ -127,11 +130,16 @@ func statusRows(p *project, lf *lockfile.Lockfile, lockFound bool) ([][3]string,
return nil, err
}

mode, err := resolveMode()
if err != nil {
return nil, err
}

var rows [][3]string

// Check each manifest dep.
for _, dep := range p.manifest.Deps {
status := depStatus(p.root, sr, p.manifest, dep, lf, lockFound)
status := depStatus(p.root, sr, mode, p.manifest, dep, lf, lockFound)

locked := "-"

Expand Down Expand Up @@ -173,6 +181,7 @@ func statusRows(p *project, lf *lockfile.Lockfile, lockFound bool) ([][3]string,
// depStatus returns the status string for a single manifest dep.
func depStatus(
root, sr string,
mode vendordir.Mode,
m *config.Manifest,
dep config.Dep,
lf *lockfile.Lockfile,
Expand Down Expand Up @@ -201,9 +210,16 @@ func depStatus(
}
}

// A link-mode dest is validated by its target, not by hashing it; a
// copy-mode dest is hashed (spec §5.6, §4.4).
if _, err := os.Readlink(destAbs); err == nil {
// A link-mode dest is validated by its target, a copy-mode dest by
// hashing (spec §5.4, §4.5). A dest materialized in the other mode is
// exactly the drift apply would rewrite (spec §5.4), so it reports
// modified — status ok must mean apply is a no-op under the same mode.
_, readlinkErr := os.Readlink(destAbs)
if isLink := readlinkErr == nil; isLink != (mode == vendordir.ModeLink) {
return statusModified
}

if mode == vendordir.ModeLink {
return linkStatus(sr, destAbs, *ld)
}

Expand Down
12 changes: 7 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ After rewriting its own entry in `graft.toml`, `add` finishes with full `graft l

When there are no dependencies to report, prints `✓ no dependencies` and exits with code 0, rather than exiting silently.
- In link mode (§5.4), the vendor check inspects the link target: pointing at `store/<locked hash>` is `ok`, a wrong target is `modified`, a dangling link is `missing`. To verify the integrity of a store entry itself, use `graft cache verify`.
- `status` judges each dest by the current mode: a dest materialized in the other mode — a symlinked dest in copy mode, a real tree in link mode — reports `modified`, which is exactly the drift `apply` would rewrite (§5.4). An all-`ok` status therefore guarantees `apply` is a no-op under the same mode.
- Exits with code 0 when everything is `ok`. A toml↔lock disagreement (`out of sync`) exits with code 2 — the same lockfile-sync failure code as `lock --check` and `apply`; pure vendor drift (`missing`/`modified`/`extra`) exits with code 1. When both occur, the more severe code 2 wins. This lets `graft status` serve as a low-cost CI gate (for example, verifying that a committed `vendor/` has not been hand-edited) without changing anything.

### 4.6 Exit codes
Expand Down Expand Up @@ -323,7 +324,8 @@ for each dependency (parallel, N workers):
│◄──────────────────────────────────────┘
materialize store/<hash> into <dest>
(copy mode: reflink/copy staged under <dir>/.graft-tmp then rename;
(copy mode: reflink/copy staged under <dir>/.graft-tmp,
re-verify the hash, then rename;
link mode: create a symlink / junction)
Expand All @@ -333,7 +335,7 @@ remove items under <dir> matching no locked dest
print summary
```

The checkout staging area lives in `<cache>/tmp/`, on the same filesystem as the store, so the rename into `store/` is atomic; if another process is building the same entry concurrently, the loser of the rename race simply uses the existing entry. Copy-mode materialization is staged under `<dir>/.graft-tmp/` rather than the system temp directory — so that the final move into `<dest>` is an atomic same-filesystem rename. Leftover items from an interrupted run in either staging area are cleaned up on the next state-modifying command, and `.graft-tmp` is never treated as a surplus dependency during reconcile.
The checkout staging area lives in `<cache>/tmp/`, on the same filesystem as the store, so the rename into `store/` is atomic; if another process is building the same entry concurrently, the loser of the rename race simply uses the existing entry. Copy-mode materialization is staged under `<dir>/.graft-tmp/` rather than the system temp directory — so that the final move into `<dest>` is an atomic same-filesystem rename. A reconcile removes `.graft-tmp` when it finishes — on failure as well as on success; leftover items from an interrupted run in either staging area are also cleaned up on the next state-modifying command, and `.graft-tmp` is never treated as a surplus dependency during reconcile.

### 5.2 Parallelism

Expand Down Expand Up @@ -373,11 +375,11 @@ All downloads flow through a user-level cache (default: the OS user cache direct

**Bare-repo cache.** The key is always the canonical `<host>/<org>/<repo>` form (scheme, userinfo, and the `.git` suffix stripped), regardless of how `repo` is written — `https://github.com/org/repo`, `github.com/org/repo`, and `git@github.com:org/repo.git` all share one entry. One advisory file lock per repository serializes concurrent fetches into the same bare repo; the rest of the cache is lock-free via atomic renames. Any commit ever fetched can be reinstalled offline.

**Content store.** A store entry is the complete installed tree for a given lockfile `hash`: checked out to `tmp/`, hashed, verified, then atomically renamed into place with all files made read-only. Because the key *is* the hash recorded in `graft.lock`, a store hit needs neither network nor re-hashing. Two benefits fall out naturally: `graft lock` fills the store while it computes the hash, so the following `graft apply` installs with no re-download; and content that is byte-for-byte identical — even from different repos or versions — is stored only once per machine.
**Content store.** A store entry is the complete installed tree for a given lockfile `hash`: checked out to `tmp/`, hashed, verified, then atomically renamed into place with all files made read-only. Because the key *is* the hash recorded in `graft.lock`, a store hit needs no network access. Two benefits fall out naturally: `graft lock` fills the store while it computes the hash, so the following `graft apply` installs with no re-download; and content that is byte-for-byte identical — even from different repos or versions — is stored only once per machine.

**Materialization.** How a store entry becomes `<dest>` is selected by the `GRAFT_LINK_MODE` environment variable. It is a machine-local choice that every materializing command (`apply`, `add`, `remove`) honors identically — there is no per-command flag, and it is never recorded in `graft.toml` or `graft.lock`. For a one-off, set it for a single command (`GRAFT_LINK_MODE=symlink graft apply`). The two mode names (`copy`, `symlink`) mirror uv's link-mode vocabulary; graft deliberately supports only these two:

- **copy** (default) — uses copy-on-write reflink when the filesystem supports it (APFS, btrfs, XFS, ReFS), otherwise a plain copy. Observable behavior is exactly the same as graft without a cache, including the commit-`vendor/` workflow, and `apply` still re-verifies the vendor tree's hash on every run.
- **copy** (default) — uses copy-on-write reflink when the filesystem supports it (APFS, btrfs, XFS, ReFS), otherwise a plain copy. Observable behavior is exactly the same as graft without a cache, including the commit-`vendor/` workflow, and `apply` still re-verifies the vendor tree's hash on every run; the tree materialized from the store is also re-hashed before it reaches the vendor directory — a corrupted store entry fails with exit code 4 and is removed (the next run re-fetches it), and is never installed.
- **symlink** (opt-in: `GRAFT_LINK_MODE=symlink`) — `<dest>` becomes a single directory symlink pointing at the store (a junction on Windows, requiring no admin privileges), registered in `links/`. Any number of projects share one on-disk file tree. Verification reduces to a cheap link-target comparison: pointing at `store/<locked hash>` is `ok`, a wrong target is `modified`, a dangling link is `missing`. Limitations: `vendor/` must be gitignored (committing a link is meaningless to other machines), and vendor integrity then rests on the store's immutability — files are read-only, so an accidental edit through the link fails immediately. During reconcile, a dest found materialized in the other mode is treated as drift and rewritten in the current mode.

The cache is purely a performance layer: deleting the entire cache is always safe, and no lockfile guarantee depends on it. GC and inspection are provided by `graft cache` (§4.7).
Expand Down Expand Up @@ -440,7 +442,7 @@ error: could not clone "shared-scripts"

**Path safety.** `dir` must be a relative path inside the repository; `name` names a path under `<dir>` (and `subdir` selects a subdirectory of the remote repo) — absolute paths, `..` segments, and any `.git` segment (which would let the destructive vendor reconcile overlap the git repository) are rejected at the validation stage (exit code 2); a `name` whose first segment starts with the reserved `.graft-` prefix (matched case-insensitively, so it also catches case variants that collide on case-insensitive filesystems) is likewise rejected (exit code 2), since that prefix is reserved for the reconcile staging directory and other internal directories, and such an install path would overlap one of them and could never apply; the fully-resolved install path (`<dir>/<name>`) always lands inside the install tree, so a malicious or corrupt manifest/lockfile can never direct an install, or a reconcile delete, outside it. Within the fetched file tree, git itself refuses to track paths containing `..` or `.git`, so a malicious dependency cannot escape its own install root either.

**Shared cache.** The cache (§5.4) is user-level and in the same trust domain as the projects that use it. Every store entry is hash-verified at creation and kept read-only; `graft cache verify` can re-check all entries at any time. In copy mode, `apply` re-verifies the vendor tree on every run, exactly as without a cache.
**Shared cache.** The cache (§5.4) is user-level and in the same trust domain as the projects that use it. Every store entry is hash-verified at creation and kept read-only; `graft cache verify` can re-check all entries at any time. In copy mode, `apply` re-verifies the vendor tree on every run and re-hashes the tree it materializes from the store before it reaches the vendor directory, exactly as without a cache — a corrupted entry fails with exit code 4 and is removed, never reaching vendor.

**HTTPS by default.** A scheme-less repository path (`github.com/org/repo`) is fetched over HTTPS; explicit `https://` or SSH URLs (`git@github.com:org/repo.git`) are also accepted. Because graft invokes external `git`, all git credential mechanisms — credential helpers, `~/.netrc`, SSH agent, and user-level `url.<base>.insteadOf` rewrites (for example, globally forcing SSH) — apply automatically with no extra configuration.

Expand Down
Loading
Loading