2828 // ErrNoTargets is returned by Eject when the lock entry records no
2929 // installed targets, leaving no agent dir to eject the copy into.
3030 ErrNoTargets = errors .New ("entry has no installed targets to eject into" )
31+ // ErrCannotVendorLink is returned by VendorIntoRepo for a link install:
32+ // its bytes live at an external path with no store worktree to copy in.
33+ ErrCannotVendorLink = errors .New ("cannot vendor a link install" )
34+ // ErrCannotEjectVendor is returned by EjectToTarget for a vendored entry:
35+ // its files are already real and writable in the repo, so there is nothing
36+ // to eject.
37+ ErrCannotEjectVendor = errors .New ("cannot eject a vendored install — its files are already editable in the repo" )
3138)
3239
3340// EjectRequest drives a `qvr edit` eject.
@@ -79,6 +86,12 @@ func EjectToTarget(req EjectRequest) (*EjectResult, error) {
7986 if e .IsLink () {
8087 return nil , ErrCannotEjectLink
8188 }
89+ if e .IsVendor () {
90+ // A vendored skill is already real, writable, in-repo files — there is
91+ // no store worktree to eject and the canonical target is a real dir, so
92+ // the eject rename would refuse to clobber it. Reject explicitly.
93+ return nil , ErrCannotEjectVendor
94+ }
8295 if len (e .Targets ) == 0 {
8396 return nil , ErrNoTargets
8497 }
@@ -112,6 +125,12 @@ func EjectToTarget(req EjectRequest) (*EjectResult, error) {
112125
113126 siblingLinks , err := repointSiblingTargets (e , req , canonicalTarget , canonicalAbs )
114127 if err != nil {
128+ // materializeEjectDir already created the canonical real directory; a
129+ // sibling-relink failure must not leave it behind. rollbackLinks (in the
130+ // caller) only removes symlinks, so the real dir would orphan and block
131+ // future installs of this skill. Remove it here to keep eject/vendor
132+ // atomic — the caller still rolls back the target symlinks.
133+ _ = os .RemoveAll (canonicalAbs )
115134 return nil , err
116135 }
117136
@@ -128,47 +147,66 @@ func EjectToTarget(req EjectRequest) (*EjectResult, error) {
128147// materializeEjectDir resolves the shared-worktree source, copies the skill
129148// subtree into a staging sibling dir, restores write bits (the immutable→editable
130149// hinge), inits a fresh git history, then atomically renames the staging dir onto
131- // the canonical slot — refusing to clobber a real (non-symlink) dir there. The
132- // staging-then-rename avoids leaving half-copied state if any step fails midway .
150+ // the canonical slot. Thin wrapper over copyTreeToCanonical with the edit-mode
151+ // settings (nested git history seeded) .
133152func materializeEjectDir (e * model.LockEntry , req EjectRequest , canonicalAbs string ) error {
134- // Resolve the shared worktree source: where we'll copy the skill files
135- // from. Falls back to LoadFromPath when the worktree isn't on disk so a
136- // user who deleted ~/.quiver/worktrees/ before invoking edit gets a clean
137- // error instead of an empty copy.
138- sourceDir := EffectiveTarget (e , req .ProjectRoot )
153+ return copyTreeToCanonical (e , req .ProjectRoot , canonicalAbs , ".ejecting" , true , req .Author , req .AuthorEmail )
154+ }
155+
156+ // copyTreeToCanonical resolves the entry's current effective source (the shared
157+ // store worktree or the local copy), copies it into a staging sibling of
158+ // canonicalAbs, restores write bits, optionally seeds a fresh nested git history,
159+ // then atomically renames the staging dir onto the canonical agent-dir slot —
160+ // refusing to clobber a real (non-symlink) dir there. The staging-then-rename
161+ // avoids leaving half-copied state if any step fails midway.
162+ //
163+ // Shared by `qvr edit` (initNestedRepo=true: the eject dir is a standalone fork
164+ // that `qvr publish` later pushes) and `qvr add --vendor` (initNestedRepo=false:
165+ // the bytes are tracked by the OUTER project repo, so a nested .git would be both
166+ // redundant and a committed-repo-inside-a-repo footgun).
167+ func copyTreeToCanonical (e * model.LockEntry , projectRoot , canonicalAbs , stagingSuffix string , initNestedRepo bool , author , authorEmail string ) error {
168+ // Resolve the source: where we'll copy the skill files from. Returns ""
169+ // when the worktree isn't on disk so a user who deleted ~/.quiver/worktrees/
170+ // gets a clean error instead of an empty copy.
171+ sourceDir := EffectiveTarget (e , projectRoot )
139172 if sourceDir == "" {
140- return fmt .Errorf ("eject %s: no source worktree to copy from — run `qvr sync` first" , e .Name )
173+ return fmt .Errorf ("%s: no source worktree to copy from — run `qvr sync` first" , e .Name )
141174 }
142175 if _ , err := os .Stat (filepath .Join (sourceDir , "SKILL.md" )); err != nil {
143- return fmt .Errorf ("eject %s: source %s does not contain SKILL.md: %w" , e .Name , sourceDir , err )
176+ return fmt .Errorf ("%s: source %s does not contain SKILL.md: %w" , e .Name , sourceDir , err )
144177 }
145178
146179 // Stage to a sibling tmp dir, then rename onto canonical. Avoids leaving
147180 // half-copied state if the copy / git init fails midway.
148- stagingDir := canonicalAbs + ".ejecting"
181+ stagingDir := canonicalAbs + stagingSuffix
149182 _ = os .RemoveAll (stagingDir )
150183 if err := copyDir (sourceDir , stagingDir ); err != nil {
151184 _ = os .RemoveAll (stagingDir )
152185 return fmt .Errorf ("copy skill tree: %w" , err )
153186 }
154- // The shared worktree is frozen read-only and copyDir preserves source
155- // modes, so the freshly-copied edit tree would be read-only. Restore write
156- // bits: this copy is the editable working dir, and initEjectRepo is about
157- // to `git init` and write into it. This is the immutable→editable hinge.
187+ // The source worktree is frozen read-only and copyDir preserves source
188+ // modes, so the freshly-copied tree would be read-only. Restore write bits:
189+ // this copy is the working dir the user (and any `git init` below) writes to.
158190 setSubtreeWritable (stagingDir )
159- if err := initEjectRepo (stagingDir , e , req .Author , req .AuthorEmail ); err != nil {
160- _ = os .RemoveAll (stagingDir )
161- return fmt .Errorf ("init edit repo: %w" , err )
191+ if initNestedRepo {
192+ if err := initEjectRepo (stagingDir , e , author , authorEmail ); err != nil {
193+ _ = os .RemoveAll (stagingDir )
194+ return fmt .Errorf ("init edit repo: %w" , err )
195+ }
162196 }
197+ return promoteStagingOntoCanonical (e , stagingDir , canonicalAbs )
198+ }
163199
164- // Remove the existing canonical symlink (or no-op if it isn't there) so
165- // the rename below lands on a clean slot. CreateSymlink earlier may have
166- // pointed it at the shared worktree; if a real dir is there already we
167- // refuse to clobber user content.
200+ // promoteStagingOntoCanonical removes the existing canonical symlink (or no-op
201+ // if absent) so the rename lands on a clean slot, then atomically renames the
202+ // staging dir onto it. CreateSymlink earlier may have pointed the canonical at
203+ // the shared worktree; if a real dir is there already we refuse to clobber user
204+ // content. On any failure the staging dir is removed so no half-state lingers.
205+ func promoteStagingOntoCanonical (e * model.LockEntry , stagingDir , canonicalAbs string ) error {
168206 if existing , err := os .Lstat (canonicalAbs ); err == nil {
169207 if existing .Mode ()& os .ModeSymlink == 0 {
170208 _ = os .RemoveAll (stagingDir )
171- return fmt .Errorf ("eject %s: %s exists and is not a symlink — refuse to overwrite" , e .Name , canonicalAbs )
209+ return fmt .Errorf ("%s: %s exists and is not a symlink — refuse to overwrite" , e .Name , canonicalAbs )
172210 }
173211 if err := os .Remove (canonicalAbs ); err != nil {
174212 _ = os .RemoveAll (stagingDir )
@@ -181,11 +219,116 @@ func materializeEjectDir(e *model.LockEntry, req EjectRequest, canonicalAbs stri
181219 }
182220 if err := os .Rename (stagingDir , canonicalAbs ); err != nil {
183221 _ = os .RemoveAll (stagingDir )
184- return fmt .Errorf ("finalize edit dir: %w" , err )
222+ return fmt .Errorf ("finalize canonical dir: %w" , err )
185223 }
186224 return nil
187225}
188226
227+ // VendorRequest drives a `qvr add --vendor` post-install vendoring step.
228+ type VendorRequest struct {
229+ Entry * model.LockEntry // the freshly-installed lock entry (mutated in place on success)
230+ ProjectRoot string // absolute project root
231+ // Global, when true, vendors into the user-global agent dir and writes
232+ // VendorPath as an absolute path; project scope (default) writes a
233+ // project-relative VendorPath so the lockfile stays portable across clones.
234+ Global bool
235+ }
236+
237+ // VendorResult summarises a vendoring for the caller.
238+ type VendorResult struct {
239+ Skill string `json:"skill"`
240+ CanonicalTarget string `json:"canonical_target"`
241+ VendorPath string `json:"vendor_path"` // project-relative (or absolute when Global)
242+ SiblingLinks []string `json:"sibling_links"` // absolute paths of repointed sibling symlinks
243+ }
244+
245+ // VendorIntoRepo promotes a freshly-installed skill's store worktree into a real
246+ // directory committed into the repo at the alphabetical-first installed target,
247+ // and repoints any other installed targets at it via relative symlinks. The lock
248+ // entry is mutated in place: Mode flips to "vendor", VendorPath records the
249+ // project-relative canonical path, and SubtreeHash is re-sealed against the
250+ // in-repo dir.
251+ //
252+ // Unlike EjectToTarget it seeds NO nested git history — the vendored bytes are
253+ // tracked by the OUTER project repo, which is exactly what lets the skill travel
254+ // with a `git clone` (no store, no registry, no qvr needed to read it). It is the
255+ // `--vendor` counterpart to a normal symlink-into-store install.
256+ //
257+ // Idempotent: a second call when Mode is already "vendor" at the same path is a
258+ // no-op. Refuses link installs (no store worktree to copy from).
259+ func VendorIntoRepo (req VendorRequest ) (* VendorResult , error ) {
260+ e := req .Entry
261+ if e == nil {
262+ return nil , errors .New ("vendor: nil entry" )
263+ }
264+ if req .ProjectRoot == "" {
265+ return nil , errors .New ("vendor: project root is required" )
266+ }
267+ if e .IsLink () {
268+ return nil , ErrCannotVendorLink
269+ }
270+ if len (e .Targets ) == 0 {
271+ return nil , ErrNoTargets
272+ }
273+
274+ canonicalTarget := pickCanonicalTarget (e .Targets )
275+ t , ok := model .LookupTarget (canonicalTarget )
276+ if ! ok {
277+ return nil , fmt .Errorf ("%w: %s" , ErrUnknownTarget , canonicalTarget )
278+ }
279+
280+ // Reuse the eject path-derivation: --global → absolute canonical/VendorPath,
281+ // project → project-relative. The EjectRequest carries only scope here.
282+ scope := EjectRequest {ProjectRoot : req .ProjectRoot , Global : req .Global }
283+ canonicalAbs , vendorPathForLock , err := ejectCanonicalPaths (t , e , scope )
284+ if err != nil {
285+ return nil , err
286+ }
287+
288+ // Idempotent no-op when the entry already records this exact vendoring.
289+ if e .IsVendor () && e .VendorPath == vendorPathForLock {
290+ return & VendorResult {Skill : e .Name , CanonicalTarget : canonicalTarget , VendorPath : vendorPathForLock }, nil
291+ }
292+
293+ if err := copyTreeToCanonical (e , req .ProjectRoot , canonicalAbs , ".vendoring" , false , "" , "" ); err != nil {
294+ return nil , err
295+ }
296+
297+ siblingLinks , err := repointSiblingTargets (e , scope , canonicalTarget , canonicalAbs )
298+ if err != nil {
299+ // copyTreeToCanonical already materialized the canonical real directory;
300+ // a sibling-relink failure must not leave it behind. The caller's
301+ // rollbackLinks only removes symlinks, so the real dir would orphan and
302+ // block future installs of this skill at that path. Remove it so vendor
303+ // stays atomic (matching EjectToTarget).
304+ _ = os .RemoveAll (canonicalAbs )
305+ return nil , err
306+ }
307+
308+ finalizeVendoredEntry (e , vendorPathForLock , canonicalAbs )
309+
310+ return & VendorResult {
311+ Skill : e .Name ,
312+ CanonicalTarget : canonicalTarget ,
313+ VendorPath : vendorPathForLock ,
314+ SiblingLinks : siblingLinks ,
315+ }, nil
316+ }
317+
318+ // finalizeVendoredEntry mutates the entry on success: flips it to mode:vendor,
319+ // records the VendorPath, and re-seals SubtreeHash against the in-repo dir so
320+ // drift detection (`qvr lock verify`) has a current baseline. The copy is
321+ // byte-identical to the store worktree, so the hash normally matches what the
322+ // install already recorded; re-hashing is defensive and matches eject's pattern.
323+ // HashSubtreeFromDisk excludes .git/, so the seal agrees with a later verify.
324+ func finalizeVendoredEntry (e * model.LockEntry , vendorPathForLock , canonicalAbs string ) {
325+ e .Mode = model .ModeVendor
326+ e .VendorPath = vendorPathForLock
327+ if h , err := canonical .HashSubtreeFromDisk (canonicalAbs ); err == nil {
328+ e .SubtreeHash = h
329+ }
330+ }
331+
189332// ejectCanonicalPaths computes the canonical eject dir and the EditPath recorded
190333// in the lock, scoped per issue #82:
191334//
0 commit comments