Compare commits

...
5 Commits
Author SHA1 Message Date
m3ta-chiron 5e7d693699 build(release): fix gitea config key (gitea_urls) and pin owner/name
giteaurls is not a goreleaser key — the correct one is gitea_urls. The
scp-style origin remote (gitea@host:owner/repo.git) is also an
unreliable source for owner/name derivation, so release.gitea pins
them explicitly.
2026-10-02 13:05:41 +02:00
m3ta-chiron a913baa496 build(release): add giteaurls block so goreleaser targets code.m3ta.dev
GITEA_URL as an env var does nothing in goreleaser v2; the giteaurls
config block is what routes scm releases to the Gitea API. Without it
the publish step fails with: scm releases: invalid URL: "".
2026-10-02 13:04:06 +02:00
m3ta-chiron 0c779bc970 feat(vendor): --exclude plus content-type selection (--types/--exclude-types)
vendor add gains --exclude a,b,c (all mode minus those ids) as the mirror
of --include, for taking the majority of a source while dropping a few
items. Mutual exclusion with --include is a hard error, unknown ids fail
naming the available ones (generalized checkIDEntries).

vendor add and vendor select gain --types (allow-list) and --exclude-types
(deny-list) over the four content types. The filter is stored as the
optional, v2-compatible Selection.exclude_types field and survives vendor
update: new upstream items of an excluded type never enter the work
repository (unlike id expansion, which would silently re-admit them).
--types/--exclude-types replace the filter on select, absent flags keep
it, and --mode all clears it along with include/exclude.

Also fixes materializeTree to create the staging directory for an empty
selection — previously select-to-zero failed at the staging rename.
2026-10-02 12:35:39 +02:00
m3ta-chiron 1ab97bff43 feat(client): sync --force to escape the locally-modified skip
Managed items with local modifications were kept (skip + warn) with no
escape hatch other than deleting the deployed file by hand — after an
upstream change the deployed content silently stayed old, which reads
exactly like a stale cache.

--force turns the two skip cases into update (repository version wins)
and remove (upstream deleted + modified). Unmanaged items and never-
managed path occupancies stay untouched even with --force; the default
behavior is unchanged.
2026-10-02 12:34:57 +02:00
m3ta-chiron 2017329015 fix(client): resolve default branch from the remote advertisement
A cached bare clone's HEAD points at refs/heads/<branch>, which is written
once at clone time and never updated again — fetch only touches
refs/remotes/*. Syncs without --ref therefore resolved the frozen
clone-time revision forever: the literal "cached version of the repo"
symptom.

Resolve the default branch via remote.List instead: the advertised HEAD
(plain hash or symref target) is the only source of truth. Verified
against the production Forgejo instance; e2e test fails on the pre-fix
binary and passes now.
2026-10-02 12:34:41 +02:00
17 changed files with 783 additions and 54 deletions

No files matched your search

+15 -9
View File
@@ -3,10 +3,21 @@ version: 2
project_name: agent-lib project_name: agent-lib
# Releases gehen an die eigene Gitea-Instanz (NICHT GitHub): # Releases gehen an die eigene Gitea-Instanz (NICHT GitHub):
# GITEA_URL=https://code.m3ta.dev # GITEA_TOKEN=<token mit repository-write> als Umgebungsvariable
# GITEA_TOKEN=<token mit repository-write> # Owner/Name sind explizit gesetzt, weil das SCP-Stil-Remote
# Repo/Owner werden aus dem origin-Remote abgeleitet # (gitea@code.m3ta.dev:m3tam3re/agent-lib.git) fuer die automatische
# (gitea@code.m3ta.dev:m3tam3re/agent-lib.git). # Ableitung unzuverlaessig ist.
gitea_urls:
api: https://code.m3ta.dev/api/v1
download: https://code.m3ta.dev
release:
draft: false
prerelease: auto
mode: append
gitea:
owner: m3tam3re
name: agent-lib
before: before:
hooks: hooks:
@@ -43,11 +54,6 @@ archives:
# .../releases/download/<tag>/agent-lib_<os>_<arch>.<ext> # .../releases/download/<tag>/agent-lib_<os>_<arch>.<ext>
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
release:
draft: false
prerelease: auto
mode: append
changelog: changelog:
sort: asc sort: asc
filters: filters:
+29 -5
View File
@@ -89,11 +89,23 @@ executed.
- Default: vendor **everything** (`mode: all`). - Default: vendor **everything** (`mode: all`).
- `--include a,b,c` switches the source to **include mode**: only those ids - `--include a,b,c` switches the source to **include mode**: only those ids
arrive, and new upstream items do **not** arrive automatically. arrive, and new upstream items do **not** arrive automatically.
- `--exclude a,b,c` keeps **all mode** minus those ids — for taking the
majority of a source while dropping a few items.
- `--types skills,commands` (**allow-list**) and `--exclude-types agents,mcp`
(**deny-list**) filter whole content types. The filter is stored as
`exclude_types` in the lockfile and **survives updates**: new upstream items
of an excluded type never enter the work repository. `--types` covering all
four types normalizes to no filter; excluding all four fails at the
zero-selection guard.
- Hard errors, enforced by `vendor add`, `vendor update`, `vendor select` - Hard errors, enforced by `vendor add`, `vendor update`, `vendor select`
and `validate`: and `validate`:
- `all` combined with an include list → error - `all` combined with an include list → error
- an exclude list without `all` → error (not supported in v2 selections) - an exclude list without `all` → error (not supported in v2 selections)
- an include entry that no longer exists upstream → error naming the item - an include or exclude entry that no longer exists upstream → error naming the item
- `--include` combined with `--exclude` → error
- `--types` combined with `--exclude-types` → error
- unknown type names → error listing `skills, commands, agents, mcp`
- an include entry belonging to a type the type filter excludes → error
(`vendor select` changes selections later — see (`vendor select` changes selections later — see
[Changing your selection later](#changing-your-selection-later) below.) [Changing your selection later](#changing-your-selection-later) below.)
@@ -107,6 +119,7 @@ advancing the pinned revision (that is `vendor update`'s job):
agent-lib vendor select superpowers --add tdd,brainstorm # include mode: grow agent-lib vendor select superpowers --add tdd,brainstorm # include mode: grow
agent-lib vendor select superpowers --remove review,debug # include mode: shrink agent-lib vendor select superpowers --remove review,debug # include mode: shrink
agent-lib vendor select superpowers --remove review # all mode: move to exclude agent-lib vendor select superpowers --remove review # all mode: move to exclude
agent-lib vendor select superpowers --types skills # replace the type filter
agent-lib vendor select superpowers --mode all # reset: take everything again agent-lib vendor select superpowers --mode all # reset: take everything again
``` ```
@@ -116,8 +129,11 @@ agent-lib vendor select superpowers --mode all # reset: take everyth
- **all mode**: `--remove` moves ids onto the exclude list (lockfile v2 - **all mode**: `--remove` moves ids onto the exclude list (lockfile v2
supports exclude in all mode) and deletes their folders; `--add` is a hard supports exclude in all mode) and deletes their folders; `--add` is a hard
error — everything is already selected. error — everything is already selected.
- `--mode all` clears include/exclude and re-materializes the full pinned - `--types` / `--exclude-types` **replace** the content-type filter
upstream, reporting what appeared. (same semantics as on `vendor add`); when neither flag is passed, the
stored filter stays as-is.
- `--mode all` clears include/exclude **and the type filter** and
re-materializes the full pinned upstream, reporting what appeared.
Every select re-runs the collision checks and honors the lockfile rename map Every select re-runs the collision checks and honors the lockfile rename map
— you can hand-add a rename for a not-yet-selected item, then `--add` it — you can hand-add a rename for a not-yet-selected item, then `--add` it
@@ -196,8 +212,16 @@ repository, the local manifest and the on-disk content:
- upstream deleted & unmodified → **remove** - upstream deleted & unmodified → **remove**
- upstream deleted & modified → **skip + warn** — your version stays - upstream deleted & modified → **skip + warn** — your version stays
With `--force` the two skip cases turn into **update** and **remove** —
the repository version wins over local modifications. Unmanaged items
(leave + warn) are never touched, even with `--force`; neither are paths
occupied by items agent-lib never deployed.
Sync is idempotent (a second run is a byte-level no-op) and fails cleanly: Sync is idempotent (a second run is a byte-level no-op) and fails cleanly:
an unreachable repository never leaves a half-deployed machine. an unreachable repository never leaves a half-deployed machine. The default
branch is resolved from the remote's advertised `HEAD`, so a sync without
`--ref` always tracks the server's current default branch — even after the
cache has been re-fetched many times.
### Configuration ### Configuration
@@ -272,7 +296,7 @@ flag that drives the non-zero exit code — suitable for headless drift checks.
| `error: … include entry "x" not found upstream` | Typo, or the item was renamed/removed upstream. Fix the lockfile include list, or re-add with a corrected `--include`. | | `error: … include entry "x" not found upstream` | Typo, or the item was renamed/removed upstream. Fix the lockfile include list, or re-add with a corrected `--include`. |
| `error: … collides with own skills "x"` | Deployed-name collision. Add `--rename <upstream>=<deployed>` (add) or fix the rename map (update). | | `error: … collides with own skills "x"` | Deployed-name collision. Add `--rename <upstream>=<deployed>` (add) or fix the rename map (update). |
| `validate` reports tree/lockfile divergence | Someone edited `external/` by hand. Re-run `vendor update <source>` to restore the pinned state. | | `validate` reports tree/lockfile divergence | Someone edited `external/` by hand. Re-run `vendor update <source>` to restore the pinned state. |
| Skill didn't update, `skipped (local modifications kept): 1` | Working as designed — you edited the deployed item. Restore the company version by deleting the local copy and re-syncing. | | Skill didn't update, `skipped (local modifications kept): 1` | Working as designed — you edited the deployed item. Run `agent-lib sync --force` to overwrite local modifications with the repository version, or delete the local copy and re-sync. |
| `status` exits 1 | Local modifications block repository updates (or occupy names the repo needs). See `status` output for the item list. | | `status` exits 1 | Local modifications block repository updates (or occupy names the repo needs). See `status` output for the item list. |
| Sync fails with clone/fetch error | Work repository unreachable, or token expired. Previous local state remains intact; fix config/network and re-run. | | Sync fails with clone/fetch error | Work repository unreachable, or token expired. Previous local state remains intact; fix config/network and re-run. |
| No toast on Windows | Toasts need an interactive user session — the scheduled task must run as the logged-in user, not SYSTEM. | | No toast on Windows | Toasts need an interactive user session — the scheduled task must run as the logged-in user, not SYSTEM. |
+7 -2
View File
@@ -10,6 +10,7 @@ import (
func newSyncCmd() *cobra.Command { func newSyncCmd() *cobra.Command {
var configPath, repoURL, ref string var configPath, repoURL, ref string
var force bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "sync", Use: "sync",
Short: "Pull the work repository and deploy its content to OpenCode", Short: "Pull the work repository and deploy its content to OpenCode",
@@ -17,10 +18,13 @@ func newSyncCmd() *cobra.Command {
"into the OpenCode runtime paths. The repository comes from --repo, " + "into the OpenCode runtime paths. The repository comes from --repo, " +
"or from the config chain: --config, " + client.ConfigEnv + " env, the " + "or from the config chain: --config, " + client.ConfigEnv + " env, the " +
"user-level config, or the admin-protected platform default. " + "user-level config, or the admin-protected platform default. " +
"Idempotent: re-running changes nothing when content and disk match.", "Idempotent: re-running changes nothing when content and disk match. " +
"Locally modified managed items are kept by default (skip + warn); " +
"--force overwrites them with the repository version and removes " +
"them after upstream deletion. Unmanaged items are never touched.",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts := client.SyncOptions{ConfigPath: configPath, RepoURL: repoURL, Ref: ref} opts := client.SyncOptions{ConfigPath: configPath, RepoURL: repoURL, Ref: ref, Force: force}
if _, err := client.Sync(opts, cmd.OutOrStdout()); err != nil { if _, err := client.Sync(opts, cmd.OutOrStdout()); err != nil {
return fmt.Errorf("sync: %w", err) return fmt.Errorf("sync: %w", err)
} }
@@ -30,5 +34,6 @@ func newSyncCmd() *cobra.Command {
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env, user config, or platform default)") cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env, user config, or platform default)")
cmd.Flags().StringVar(&repoURL, "repo", "", "work repository URL or path — overrides any config, works without one") cmd.Flags().StringVar(&repoURL, "repo", "", "work repository URL or path — overrides any config, works without one")
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to pull (requires --repo)") cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to pull (requires --repo)")
cmd.Flags().BoolVar(&force, "force", false, "overwrite locally modified managed items with the repository version; unmanaged items are never touched")
return cmd return cmd
} }
+22 -6
View File
@@ -26,7 +26,7 @@ func newVendorCmd() *cobra.Command {
func newVendorAddCmd() *cobra.Command { func newVendorAddCmd() *cobra.Command {
var ref string var ref string
var include []string var include, exclude, onlyTypes, excludeTypes []string
var renames []string var renames []string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add <name> <url>", Use: "add <name> <url>",
@@ -34,7 +34,10 @@ func newVendorAddCmd() *cobra.Command {
Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" + Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" +
"agents and MCP fragments, materializes the selection under external/<name>/\n" + "agents and MCP fragments, materializes the selection under external/<name>/\n" +
"and pins URL, ref, revision, selection and renames in the lockfile.\n" + "and pins URL, ref, revision, selection and renames in the lockfile.\n" +
"Default selection is everything; --include switches to an explicit list.\n" + "Default selection is everything; --include switches to an explicit list,\n" +
"--exclude keeps everything except the listed ids. --types (allow-list) and\n" +
"--exclude-types (deny-list) filter whole content types and survive updates:\n" +
"new upstream items of an excluded type never enter the work repository.\n" +
"Collisions abort with a hard error; resolve them with --rename upstream=deployed.", "Collisions abort with a hard error; resolve them with --rename upstream=deployed.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -47,6 +50,9 @@ func newVendorAddCmd() *cobra.Command {
URL: args[1], URL: args[1],
Ref: ref, Ref: ref,
Include: splitList(include), Include: splitList(include),
Exclude: splitList(exclude),
OnlyTypes: splitList(onlyTypes),
ExcludeTypes: splitList(excludeTypes),
Renames: renameMap, Renames: renameMap,
} }
if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil { if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil {
@@ -57,6 +63,9 @@ func newVendorAddCmd() *cobra.Command {
} }
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to track (default: the source's default branch)") cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to track (default: the source's default branch)")
cmd.Flags().StringSliceVar(&include, "include", nil, "comma-separated item ids to vendor (switches selection to include mode)") cmd.Flags().StringSliceVar(&include, "include", nil, "comma-separated item ids to vendor (switches selection to include mode)")
cmd.Flags().StringSliceVar(&exclude, "exclude", nil, "comma-separated item ids to skip (keeps all mode, drops these ids)")
cmd.Flags().StringSliceVar(&onlyTypes, "types", nil, "comma-separated content types to take: skills, commands, agents, mcp (allow-list)")
cmd.Flags().StringSliceVar(&excludeTypes, "exclude-types", nil, "comma-separated content types to skip: skills, commands, agents, mcp (deny-list)")
cmd.Flags().StringSliceVar(&renames, "rename", nil, "deployed-name override upstream-id=deployed-name (repeatable)") cmd.Flags().StringSliceVar(&renames, "rename", nil, "deployed-name override upstream-id=deployed-name (repeatable)")
return cmd return cmd
} }
@@ -75,6 +84,7 @@ func splitList(in []string) []string {
func newVendorSelectCmd() *cobra.Command { func newVendorSelectCmd() *cobra.Command {
var add, remove []string var add, remove []string
var onlyTypes, excludeTypes []string
var mode string var mode string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "select <name>", Use: "select <name>",
@@ -83,9 +93,11 @@ func newVendorSelectCmd() *cobra.Command {
"Include mode: --add appends ids (validated against the pinned upstream),\n" + "Include mode: --add appends ids (validated against the pinned upstream),\n" +
"--remove drops ids and deletes their folders. All mode: --remove moves ids\n" + "--remove drops ids and deletes their folders. All mode: --remove moves ids\n" +
"onto the exclude list; --add is an error (everything is selected already).\n" + "onto the exclude list; --add is an error (everything is selected already).\n" +
"--mode all resets to everything: include/exclude lists are cleared and the\n" + "--types/--exclude-types replace the content-type filter (skills, commands,\n" +
"full pinned upstream is re-materialized. Collisions re-run on every change;\n" + "agents, mcp); absent flags keep it as-is. --mode all resets to everything:\n" +
"renames from the lockfile are honored. The revision never moves — that is\n" + "include/exclude lists and the type filter are cleared and the full pinned\n" +
"upstream is re-materialized. Collisions re-run on every change; renames\n" +
"from the lockfile are honored. The revision never moves — that is\n" +
"vendor update's job.", "vendor update's job.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -96,6 +108,8 @@ func newVendorSelectCmd() *cobra.Command {
Add: splitList(add), Add: splitList(add),
Remove: splitList(remove), Remove: splitList(remove),
ModeAll: mode == "all", ModeAll: mode == "all",
OnlyTypes: splitList(onlyTypes),
ExcludeTypes: splitList(excludeTypes),
} }
if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil { if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil {
return fmt.Errorf("vendor select: %w", err) return fmt.Errorf("vendor select: %w", err)
@@ -105,6 +119,8 @@ func newVendorSelectCmd() *cobra.Command {
} }
cmd.Flags().StringSliceVar(&add, "add", nil, "comma-separated upstream ids to add to the selection") cmd.Flags().StringSliceVar(&add, "add", nil, "comma-separated upstream ids to add to the selection")
cmd.Flags().StringSliceVar(&remove, "remove", nil, "comma-separated upstream ids to remove from the selection") cmd.Flags().StringSliceVar(&remove, "remove", nil, "comma-separated upstream ids to remove from the selection")
cmd.Flags().StringVar(&mode, "mode", "", "switch selection mode (only \"all\": clear include/exclude, take everything)") cmd.Flags().StringSliceVar(&onlyTypes, "types", nil, "content types to take: skills, commands, agents, mcp (allow-list; replaces the type filter)")
cmd.Flags().StringSliceVar(&excludeTypes, "exclude-types", nil, "content types to skip: skills, commands, agents, mcp (deny-list; replaces the type filter)")
cmd.Flags().StringVar(&mode, "mode", "", "switch selection mode (only \"all\": clear include/exclude and the type filter, take everything)")
return cmd return cmd
} }
+12 -5
View File
@@ -47,8 +47,11 @@ type PlanEntry struct {
// Plan is a pure function from (inventory, manifest, on-disk facts) to an // Plan is a pure function from (inventory, manifest, on-disk facts) to an
// action plan. pathFor injects the target path mapping so the planner never // action plan. pathFor injects the target path mapping so the planner never
// touches the filesystem or the environment. The plan is sorted by // touches the filesystem or the environment. The plan is sorted by
// (type, name) for deterministic execution and reporting. // (type, name) for deterministic execution and reporting. With force set,
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string) []PlanEntry { // locally modified managed items are overwritten with the upstream version
// (and removed after upstream deletion) instead of being skipped; items
// agent-lib never managed are never touched, even with force.
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string, force bool) []PlanEntry {
invByKey := map[string]PlannedItem{} invByKey := map[string]PlannedItem{}
for _, it := range items { for _, it := range items {
invByKey[manifestKey(it.Type, it.Name)] = it invByKey[manifestKey(it.Type, it.Name)] = it
@@ -93,13 +96,15 @@ func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pat
emit(ActDeploy, it, "") emit(ActDeploy, it, "")
} }
case inInv && it.Hash == rec.Hash: case inInv && it.Hash == rec.Hash:
decideAgainstDisk(it, rec, fact, emit, "unchanged") decideAgainstDisk(it, rec, fact, emit, "unchanged", force)
case inInv: case inInv:
decideAgainstDisk(it, rec, fact, emit, "changed") decideAgainstDisk(it, rec, fact, emit, "changed", force)
case rec != nil && !fact.Exists: case rec != nil && !fact.Exists:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "already gone from disk; dropping manifest record") emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "already gone from disk; dropping manifest record")
case rec != nil && fact.Hash == rec.Hash: case rec != nil && fact.Hash == rec.Hash:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted") emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted")
case rec != nil && force:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted; forced removal of the locally modified copy")
case rec != nil: case rec != nil:
emit(ActSkip, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, emit(ActSkip, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin},
"upstream deleted but the local copy was modified; keeping it") "upstream deleted but the local copy was modified; keeping it")
@@ -111,7 +116,7 @@ func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pat
return plan return plan
} }
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string) { func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string, force bool) {
switch { switch {
case !fact.Exists: case !fact.Exists:
emit(ActUpdate, it, "managed item missing on disk; restoring") emit(ActUpdate, it, "managed item missing on disk; restoring")
@@ -121,6 +126,8 @@ func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit fu
} else { } else {
emit(ActUpdate, it, "") emit(ActUpdate, it, "")
} }
case force:
emit(ActUpdate, it, "forced: locally modified copy overwritten")
default: default:
emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")") emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")")
} }
+52 -4
View File
@@ -98,7 +98,7 @@ func TestPlanMatrix(t *testing.T) {
diskFacts[key] = DiskFact{Exists: true, Hash: hashMod} diskFacts[key] = DiskFact{Exists: true, Hash: hashMod}
} }
plan := Plan(items, manifest, diskFacts, testPathFor) plan := Plan(items, manifest, diskFacts, testPathFor, false)
if tc.disk == diskNone && tc.up == upAbsent { if tc.disk == diskNone && tc.up == upAbsent {
if len(plan) != 0 { if len(plan) != 0 {
t.Fatalf("a key present nowhere must produce no entry, got %+v", plan) t.Fatalf("a key present nowhere must produce no entry, got %+v", plan)
@@ -123,6 +123,54 @@ func TestPlanMatrix(t *testing.T) {
} }
} }
// TestPlanForceMatrix asserts how --force changes the skip cases: locally
// modified managed items become updates (or removes after upstream
// deletion), while never-managed occupancies stay skipped even with force.
func TestPlanForceMatrix(t *testing.T) {
cases := []struct {
name string
up string // "changed", "unchanged", "gone"
want Action
}{
{"modified/changed", "changed", ActUpdate},
{"modified/unchanged", "unchanged", ActUpdate},
{"modified/gone", "gone", ActRemove},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key := manifestKey("skills", "alpha")
var items []PlannedItem
manifest := NewManifest()
manifest.Items[key] = &ManifestItem{Name: "alpha", Type: "skills", Hash: hashV1}
if tc.up != "gone" {
hash := hashV2
if tc.up == "unchanged" {
hash = hashV1
}
items = append(items, PlannedItem{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hash})
}
disk := map[string]DiskFact{key: {Exists: true, Hash: hashMod}}
plan := Plan(items, manifest, disk, testPathFor, true)
if len(plan) != 1 {
t.Fatalf("want exactly 1 entry, got %d: %+v", len(plan), plan)
}
if plan[0].Action != tc.want {
t.Errorf("action = %s, want %s (reason: %s)", plan[0].Action, tc.want, plan[0].Reason)
}
})
}
t.Run("never-managed occupancy stays skipped", func(t *testing.T) {
items := []PlannedItem{{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hashV1}}
disk := map[string]DiskFact{manifestKey("skills", "alpha"): {Exists: true, Hash: hashMod}}
plan := Plan(items, NewManifest(), disk, testPathFor, true)
if len(plan) != 1 || plan[0].Action != ActSkip {
t.Fatalf("force must not deploy over a never-managed item: %+v", plan)
}
})
}
// TestPlanMixedFixture combines several states in one plan and asserts the // TestPlanMixedFixture combines several states in one plan and asserts the
// full deterministic output. // full deterministic output.
func TestPlanMixedFixture(t *testing.T) { func TestPlanMixedFixture(t *testing.T) {
@@ -151,7 +199,7 @@ func TestPlanMixedFixture(t *testing.T) {
"agents/helper": {Exists: true, Hash: hashV1}, // unmodified × unchanged "agents/helper": {Exists: true, Hash: hashV1}, // unmodified × unchanged
} }
plan := Plan(items, manifest, disk, testPathFor) plan := Plan(items, manifest, disk, testPathFor, false)
got := map[string]Action{} got := map[string]Action{}
for _, e := range plan { for _, e := range plan {
got[e.Type+"/"+e.Name] = e.Action got[e.Type+"/"+e.Name] = e.Action
@@ -195,8 +243,8 @@ func TestPlanPureGuarantee(t *testing.T) {
manifest := manifestWith(map[string]*ManifestItem{"skills/b": {Name: "b", Type: "skills", Hash: hashV1}}) manifest := manifestWith(map[string]*ManifestItem{"skills/b": {Name: "b", Type: "skills", Hash: hashV1}})
disk := map[string]DiskFact{"skills/b": {Exists: true, Hash: hashV1}} disk := map[string]DiskFact{"skills/b": {Exists: true, Hash: hashV1}}
p1 := Plan(items, manifest, disk, testPathFor) p1 := Plan(items, manifest, disk, testPathFor, false)
p2 := Plan(items, manifest, disk, testPathFor) p2 := Plan(items, manifest, disk, testPathFor, false)
if len(p1) != len(p2) { if len(p1) != len(p2) {
t.Fatal("same inputs must yield same plan length") t.Fatal("same inputs must yield same plan length")
} }
+46 -1
View File
@@ -31,7 +31,15 @@ func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
hash, err := resolveRemoteRef(repo, ref) var hash plumbing.Hash
if ref == "" {
// The cached bare clone's HEAD is frozen at clone time (fetch never
// updates refs/heads/*), so the default branch must be asked from the
// remote directly — never resolved through local refs.
hash, err = resolveDefaultBranch(repo, auth)
} else {
hash, err = resolveRemoteRef(repo, ref)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("resolving %q in %s: %w", refLabel(ref), gitsource.RedactURL(url), err) return nil, fmt.Errorf("resolving %q in %s: %w", refLabel(ref), gitsource.RedactURL(url), err)
} }
@@ -86,6 +94,43 @@ func openOrCreate(cacheDir, url, ref string, auth gittransport.AuthMethod) (*git
return repo, nil return repo, nil
} }
// resolveDefaultBranch asks the remote for its advertised HEAD — the only
// source of truth for the default branch tip. HEAD may be advertised as a
// plain hash or as a symbolic reference to one of the advertised refs; both
// are handled. Local refs are never consulted: the cached bare clone's HEAD
// is frozen at clone time because fetch updates only refs/remotes/*.
func resolveDefaultBranch(repo *git.Repository, auth gittransport.AuthMethod) (plumbing.Hash, error) {
remote, err := repo.Remote(git.DefaultRemoteName)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("reading remote: %w", err)
}
refs, err := remote.List(&git.ListOptions{Auth: auth})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("listing remote refs: %w", err)
}
hashes := map[string]plumbing.Hash{}
var head *plumbing.Reference
for _, ref := range refs {
if ref.Name() == plumbing.HEAD {
head = ref
continue
}
if ref.Type() == plumbing.HashReference {
hashes[ref.Name().String()] = ref.Hash()
}
}
if head == nil {
return plumbing.ZeroHash, errors.New("remote advertises no HEAD")
}
if head.Type() == plumbing.HashReference {
return head.Hash(), nil
}
if h, ok := hashes[head.Target().String()]; ok {
return h, nil
}
return plumbing.ZeroHash, fmt.Errorf("advertised HEAD points at %s, which was not advertised", head.Target())
}
// repointRemote aligns the cached origin with the configured URL so a fleet // repointRemote aligns the cached origin with the configured URL so a fleet
// re-point takes effect on the next sync. // re-point takes effect on the next sync.
func repointRemote(repo *git.Repository, url string) error { func repointRemote(repo *git.Repository, url string) error {
+4 -2
View File
@@ -12,11 +12,13 @@ import (
// SyncOptions parameterize a client sync run. RepoURL (with optional Ref) // SyncOptions parameterize a client sync run. RepoURL (with optional Ref)
// is the inline --repo override: it works without any config file and beats // is the inline --repo override: it works without any config file and beats
// a repo_url discovered in the config chain. // a repo_url discovered in the config chain. Force switches the planner
// from skip+warn to overwrite for locally modified managed items.
type SyncOptions struct { type SyncOptions struct {
ConfigPath string ConfigPath string
RepoURL string RepoURL string
Ref string Ref string
Force bool
} }
// prepared is everything sync and status share: the pulled repository, the // prepared is everything sync and status share: the pulled repository, the
@@ -80,7 +82,7 @@ func prepare(opts SyncOptions) (*prepared, error) {
path, _ := oc.DeployPath(typ, name) path, _ := oc.DeployPath(typ, name)
return path return path
} }
plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor) plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor, opts.Force)
for _, e := range plan { for _, e := range plan {
if !safePath(e.DstPath) { if !safePath(e.DstPath) {
return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath) return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath)
+42
View File
@@ -0,0 +1,42 @@
package e2e
import (
"os"
"path/filepath"
"testing"
)
// TestSyncDefaultBranchStaysFresh pins the fix for the stale-cache bug: with
// no ref configured, sync must resolve the default branch from the remote
// advertisement, never from the cached bare clone's frozen refs/heads ref.
func TestSyncDefaultBranchStaysFresh(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
// config without "ref": the default-branch resolution path
env.config = filepath.Join(env.configDir, "config.json")
os.WriteFile(env.config, []byte(`{"repo_url": "`+env.workRepo+`"}`), 0o600)
if _, err := env.run(t); err != nil {
t.Fatal(err)
}
// upstream change on the default branch
p := filepath.Join(env.workRepo, "commands/deploy.md")
os.WriteFile(p, []byte("---\nname: Deploy\ndescription: updated\n---\nfresh body\n"), 0o644)
h.git(t, env.workRepo, "add", "-A")
h.git(t, env.workRepo, "commit", "-m", "update command on default branch")
out, err := env.run(t)
if err != nil {
t.Fatalf("second sync (default branch) failed: %v\n%s", err, out)
}
if !contains(out, "updated: 1") {
t.Errorf("default-branch sync must pick up the upstream change:\n%s", out)
}
deployed := readFile(t, filepath.Join(env.home, ".config/opencode/commands/deploy.md"))
if !contains(deployed, "fresh body") {
t.Error("default-branch sync must deploy the fresh content, not the clone-time revision")
}
}
+83
View File
@@ -0,0 +1,83 @@
package e2e
import (
"os"
"path/filepath"
"testing"
)
// TestSyncForceOverwritesLocallyModified pins the --force escape hatch:
// locally modified managed items are overwritten with the repository
// version, upstream-deleted modified items are removed, and unmanaged
// local items are never touched.
func TestSyncForceOverwritesLocallyModified(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
env.writeConfig(t, env.workRepo, "")
env.run(t)
// local drift on a managed item + upstream change to the same item
deployed := filepath.Join(env.home, ".config/opencode/commands/deploy.md")
os.WriteFile(deployed, []byte("---\nname: Deploy\n---\nlocal drift\n"), 0o644)
upstream := filepath.Join(env.workRepo, "commands/deploy.md")
os.WriteFile(upstream, []byte("---\nname: Deploy\ndescription: updated\n---\nnew body\n"), 0o644)
// an unmanaged local item that must survive --force untouched
unmanaged := filepath.Join(env.home, ".config/opencode/commands/mine.md")
os.MkdirAll(filepath.Dir(unmanaged), 0o755)
os.WriteFile(unmanaged, []byte("---\nname: Mine\n---\nprecious\n"), 0o644)
h.git(t, env.workRepo, "add", "-A")
h.git(t, env.workRepo, "commit", "-m", "update deploy command")
out, err := env.run(t)
if err != nil {
t.Fatalf("sync failed: %v\n%s", err, out)
}
if !contains(out, "skipped (local modifications kept): 1") {
t.Errorf("default sync must keep the local modification:\n%s", out)
}
out, err = env.run(t, "--force")
if err != nil {
t.Fatalf("sync --force failed: %v\n%s", err, out)
}
if !contains(out, "updated: 1") {
t.Errorf("force must update the locally modified item:\n%s", out)
}
if got := readFile(t, deployed); !contains(got, "new body") {
t.Error("force must overwrite the local drift with the repository version")
}
if got := readFile(t, unmanaged); !contains(got, "precious") {
t.Error("force must never touch unmanaged local items")
}
// upstream deletion of a locally modified item: force removes it
os.WriteFile(deployed, []byte("---\nname: Deploy\n---\nmore drift\n"), 0o644)
os.Remove(upstream)
h.git(t, env.workRepo, "add", "-A")
h.git(t, env.workRepo, "commit", "-m", "delete deploy command")
out, err = env.run(t)
if err != nil {
t.Fatalf("sync failed: %v\n%s", err, out)
}
if !contains(out, "skipped (local modifications kept): 1") {
t.Errorf("default sync must keep the modified item after upstream deletion:\n%s", out)
}
if _, err := os.Stat(deployed); err != nil {
t.Fatal("item must still exist before force")
}
out, err = env.run(t, "--force")
if err != nil {
t.Fatalf("sync --force failed: %v\n%s", err, out)
}
if !contains(out, "removed: 1") {
t.Errorf("force must remove the locally modified item after upstream deletion:\n%s", out)
}
if _, err := os.Stat(deployed); !os.IsNotExist(err) {
t.Error("forced removal must delete the deployed file")
}
}
+75
View File
@@ -0,0 +1,75 @@
package e2e
import (
"os"
"path/filepath"
"testing"
)
// TestVendorAddExcludeMode pins --exclude: all mode minus the listed ids,
// materialized and recorded in the lockfile as mode "all" with an exclude
// list.
func TestVendorAddExcludeMode(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "broken-skill,scout")
for _, p := range []string{
"external/curated/skills/good-skill/SKILL.md",
"external/curated/commands/review.md",
"external/curated/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
t.Errorf("non-excluded item must be materialized: %s", p)
}
}
for _, p := range []string{
"external/curated/skills/broken-skill/SKILL.md",
"external/curated/agents/scout.md",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil {
t.Errorf("excluded item must NOT be materialized: %s", p)
}
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"mode": "all"`) {
t.Errorf("exclude keeps all mode:\n%s", lock)
}
if !contains(lock, `"exclude": [`) {
t.Errorf("exclude list must be recorded:\n%s", lock)
}
}
// TestVendorAddExcludeTypoFailsClean pins the typo protection for --exclude.
func TestVendorAddExcludeTypoFailsClean(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "good-skill,nope")
if err == nil {
t.Fatalf("typo in exclude list must fail:\n%s", out)
}
if !contains(out, "exclude entry") && !contains(out, "not found upstream") {
t.Errorf("error must name the unknown exclude entry:\n%s", out)
}
if _, statErr := os.Stat(filepath.Join(h.workDir, "external/curated")); statErr == nil {
t.Error("failed add must not leave an external area behind")
}
}
// TestVendorAddIncludeAndExcludeConflict pins the mutual exclusion.
func TestVendorAddIncludeAndExcludeConflict(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir,
"--include", "good-skill", "--exclude", "scout")
if err == nil {
t.Fatalf("--include and --exclude together must fail:\n%s", out)
}
if !contains(out, "--include and --exclude cannot be combined") {
t.Errorf("error must explain the conflict:\n%s", out)
}
}
+162
View File
@@ -0,0 +1,162 @@
package e2e
import (
"os"
"path/filepath"
"testing"
)
// TestVendorAddTypesAllowList pins --types: only the listed content types
// enter the work repository, recorded as the canonical exclude_types
// complement.
func TestVendorAddTypesAllowList(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills,commands")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
t.Error("allowed skill must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
t.Error("allowed command must be materialized")
}
for _, p := range []string{
"external/curated/agents/scout.md",
"external/curated/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil {
t.Errorf("item of a non-allowed type must NOT be materialized: %s", p)
}
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"exclude_types": [`) {
t.Errorf("type filter must be recorded as exclude_types:\n%s", lock)
}
if !contains(lock, `"agents"`) || !contains(lock, `"mcp"`) {
t.Errorf("exclude_types must hold the complement (agents, mcp):\n%s", lock)
}
}
// TestVendorAddExcludeTypesDenyList pins --exclude-types and its unknown-type
// validation.
func TestVendorAddExcludeTypesDenyList(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude-types", "agents,mcp")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
t.Error("skill must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/scout.md")); err == nil {
t.Error("agent of an excluded type must NOT be materialized")
}
out, err := h.run(t, "vendor", "add", "other", h.upstreamDir, "--exclude-types", "skillz")
if err == nil {
t.Fatalf("unknown type must fail:\n%s", out)
}
if !contains(out, "unknown content type") || !contains(out, "skills, commands, agents, mcp") {
t.Errorf("error must list the valid type names:\n%s", out)
}
out, err = h.run(t, "vendor", "add", "other", h.upstreamDir, "--types", "skills", "--exclude-types", "mcp")
if err == nil {
t.Fatalf("--types and --exclude-types together must fail:\n%s", out)
}
}
// TestVendorTypesSurviveUpdate pins the whole point of exclude_types: items
// of an excluded type that appear upstream later must not slip back in on
// vendor update.
func TestVendorTypesSurviveUpdate(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills")
// upstream gains a new agent and a new skill
h.write(t, "agents/latecomer.md", "---\nname: Latecomer\n---\nbody\n")
h.write(t, "skills/new-skill/SKILL.md", "---\nname: New Skill\ndescription: fresh\n---\n# New\n")
h.commitUpstream(t, "add latecomer agent and new skill")
h.mustRun(t, "vendor", "update", "curated")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/new-skill/SKILL.md")); err != nil {
t.Error("new skill of an allowed type must be vendored by update")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/latecomer.md")); err == nil {
t.Error("new agent of an excluded type must NOT re-enter on update")
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"exclude_types": [`) {
t.Errorf("type filter must survive the update:\n%s", lock)
}
}
// TestVendorSelectTypeFilter pins vendor select parity: --exclude-types /
// --types replace the filter, absent flags keep it, --mode all clears it.
func TestVendorSelectTypeFilter(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills")
// replacing the filter via --types
h.mustRun(t, "vendor", "select", "curated", "--types", "commands")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err == nil {
t.Error("skill must be dropped after narrowing the type filter")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
t.Error("command must be materialized after widening the type filter")
}
// an id-only change keeps the type filter
h.mustRun(t, "vendor", "select", "curated", "--remove", "review")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err == nil {
t.Error("excluded command must be gone")
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"exclude_types": [`) || !contains(lock, `"skills"`) {
t.Errorf("type filter must survive an id-only select:\n%s", lock)
}
// --mode all clears everything, including the type filter
h.mustRun(t, "vendor", "select", "curated", "--mode", "all")
for _, p := range []string{
"external/curated/skills/good-skill/SKILL.md",
"external/curated/commands/review.md",
"external/curated/agents/scout.md",
"external/curated/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
t.Errorf("--mode all must re-materialize everything: %s", p)
}
}
lock = readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if contains(lock, `"exclude_types": [`) {
t.Errorf("--mode all must clear the type filter:\n%s", lock)
}
// --mode all combined with type flags stays an error
out, err := h.run(t, "vendor", "select", "curated", "--mode", "all", "--types", "skills")
if err == nil {
t.Fatalf("--mode all with --types must fail:\n%s", out)
}
}
// TestVendorAddIncludeVsTypeFilter pins the explicit conflict error when an
// include id belongs to a type the filter excludes.
func TestVendorAddIncludeVsTypeFilter(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir,
"--include", "scout", "--exclude-types", "agents")
if err == nil {
t.Fatalf("include id of an excluded type must fail:\n%s", out)
}
if !contains(out, "type filter excludes") {
t.Errorf("error must explain the conflict:\n%s", out)
}
}
+6 -1
View File
@@ -68,11 +68,15 @@ const (
) )
// Selection expresses what enters the work repository: everything minus // Selection expresses what enters the work repository: everything minus
// Exclude (mode all), or exactly Include (mode include). // Exclude (mode all), or exactly Include (mode include). ExcludeTypes
// filters whole content types (skills, commands, agents, mcp) on top of
// either mode and survives updates: new upstream items of an excluded
// type never enter the work repository.
type Selection struct { type Selection struct {
Mode SelectionMode `json:"mode"` Mode SelectionMode `json:"mode"`
Exclude []string `json:"exclude,omitempty"` Exclude []string `json:"exclude,omitempty"`
Include []string `json:"include,omitempty"` Include []string `json:"include,omitempty"`
ExcludeTypes []string `json:"exclude_types,omitempty"`
} }
// Source pins one vendored git source. // Source pins one vendored git source.
@@ -140,6 +144,7 @@ func (l *Lockfile) Save(path string) error {
for _, src := range l.Sources { for _, src := range l.Sources {
sort.Strings(src.Selection.Exclude) sort.Strings(src.Selection.Exclude)
sort.Strings(src.Selection.Include) sort.Strings(src.Selection.Include)
sort.Strings(src.Selection.ExcludeTypes)
sort.Strings(src.Warnings) sort.Strings(src.Warnings)
for _, items := range src.Inventory { for _, items := range src.Inventory {
sort.Strings(items) sort.Strings(items)
+112 -7
View File
@@ -25,6 +25,9 @@ type AddOptions struct {
URL string URL string
Ref string Ref string
Include []string Include []string
Exclude []string
OnlyTypes []string
ExcludeTypes []string
Renames map[string]string Renames map[string]string
} }
@@ -68,12 +71,29 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
return err return err
} }
sel := lockfile.Selection{Mode: lockfile.ModeAll} if len(opts.Include) > 0 && len(opts.Exclude) > 0 {
if len(opts.Include) > 0 { return fmt.Errorf("--include and --exclude cannot be combined (a v2 selection is either include mode or all mode minus excludes)")
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include} }
if err := checkIncludeEntries(items, opts.Include, opts.Name); err != nil { excludeTypes, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes)
if err != nil {
return err return err
} }
sel := lockfile.Selection{Mode: lockfile.ModeAll, ExcludeTypes: excludeTypes}
switch {
case len(opts.Include) > 0:
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include, ExcludeTypes: excludeTypes}
if err := checkIDEntries(items, opts.Include, opts.Name, "include"); err != nil {
return err
}
case len(opts.Exclude) > 0:
sel.Exclude = opts.Exclude
if err := checkIDEntries(items, opts.Exclude, opts.Name, "exclude"); err != nil {
return err
}
}
if err := checkIncludeVsTypes(items, sel, opts.Name); err != nil {
return err
} }
selected := applySelection(items, sel) selected := applySelection(items, sel)
@@ -147,21 +167,96 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery {
return d return d
} }
func checkIncludeEntries(items []discovery.Item, include []string, name string) error { // checkIDEntries fails when any id is not available upstream (not discovered
// or skipped) — typo protection for include and exclude lists alike.
func checkIDEntries(items []discovery.Item, ids []string, name, flag string) error {
available := map[string]bool{} available := map[string]bool{}
for _, it := range items { for _, it := range items {
if !it.Skipped { if !it.Skipped {
available[it.UpstreamID] = true available[it.UpstreamID] = true
} }
} }
for _, want := range include { for _, want := range ids {
if !available[want] { if !available[want] {
return fmt.Errorf("source %q: include entry %q not found upstream (available: %s)", name, want, strings.Join(sortedKeys(available), ", ")) return fmt.Errorf("source %q: %s entry %q not found upstream (available: %s)", name, flag, want, strings.Join(sortedKeys(available), ", "))
} }
} }
return nil return nil
} }
// checkIncludeVsTypes fails when an include id belongs to a type excluded by
// the type filter — silently dropping it would betray the explicit include.
func checkIncludeVsTypes(items []discovery.Item, sel lockfile.Selection, name string) error {
if sel.Mode != lockfile.ModeInclude || len(sel.ExcludeTypes) == 0 {
return nil
}
excluded := map[string]bool{}
for _, t := range sel.ExcludeTypes {
excluded[t] = true
}
for _, it := range items {
if it.Skipped || !excluded[it.Type] {
continue
}
for _, id := range sel.Include {
if id == it.UpstreamID {
return fmt.Errorf("source %q: include entry %q belongs to type %s, which the type filter excludes", name, id, it.Type)
}
}
}
return nil
}
// TypeFilter translates --types (allow-list) and --exclude-types (deny-list)
// into the canonical exclude_types list: the complement of the allow-list,
// or the deny-list verbatim. Both empty means no filter (nil). A filter that
// would exclude nothing normalizes to nil; a filter excluding everything is
// returned as-is and fails later at the zero-selection guard.
func TypeFilter(only, exclude []string) ([]string, error) {
if len(only) > 0 && len(exclude) > 0 {
return nil, fmt.Errorf("--types and --exclude-types cannot be combined")
}
if len(only) == 0 && len(exclude) == 0 {
return nil, nil
}
names := only
if len(names) == 0 {
names = exclude
}
valid := map[string]bool{}
for _, t := range lockfile.Types {
valid[t] = true
}
var unknown []string
for _, t := range names {
if !valid[t] {
unknown = append(unknown, t)
}
}
if len(unknown) > 0 {
return nil, fmt.Errorf("unknown content type(s) %s (valid: %s)", strings.Join(unknown, ", "), strings.Join(lockfile.Types, ", "))
}
if len(exclude) > 0 {
out := append([]string{}, exclude...)
sort.Strings(out)
return out, nil
}
allowed := map[string]bool{}
for _, t := range only {
allowed[t] = true
}
var out []string
for _, t := range lockfile.Types {
if !allowed[t] {
out = append(out, t)
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item { func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item {
exclude := map[string]bool{} exclude := map[string]bool{}
for _, e := range sel.Exclude { for _, e := range sel.Exclude {
@@ -171,11 +266,18 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.
for _, i := range sel.Include { for _, i := range sel.Include {
include[i] = true include[i] = true
} }
excludedTypes := map[string]bool{}
for _, t := range sel.ExcludeTypes {
excludedTypes[t] = true
}
var out []discovery.Item var out []discovery.Item
for _, it := range items { for _, it := range items {
if it.Skipped { if it.Skipped {
continue continue
} }
if excludedTypes[it.Type] {
continue
}
switch sel.Mode { switch sel.Mode {
case lockfile.ModeInclude: case lockfile.ModeInclude:
if include[it.UpstreamID] { if include[it.UpstreamID] {
@@ -251,6 +353,9 @@ func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selec
fmt.Fprintf(w, " rev: %s\n", src.Rev) fmt.Fprintf(w, " rev: %s\n", src.Rev)
fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered)) fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered))
fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected)) fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected))
if len(src.Selection.ExcludeTypes) > 0 {
fmt.Fprintf(w, "type filter: excluding %s\n", strings.Join(src.Selection.ExcludeTypes, ", "))
}
fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name)) fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name))
if len(src.Warnings) > 0 { if len(src.Warnings) > 0 {
fmt.Fprintf(w, "warnings:\n") fmt.Fprintf(w, "warnings:\n")
+32 -11
View File
@@ -18,6 +18,8 @@ type SelectOptions struct {
Add []string Add []string
Remove []string Remove []string
ModeAll bool ModeAll bool
OnlyTypes []string
ExcludeTypes []string
} }
// Select changes what a vendored source contributes — without advancing the // Select changes what a vendored source contributes — without advancing the
@@ -60,6 +62,9 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change
if err := validateSelectIDs(items, src, opts, name); err != nil { if err := validateSelectIDs(items, src, opts, name); err != nil {
return nil, err return nil, err
} }
if err := checkIncludeVsTypes(items, newSel, name); err != nil {
return nil, err
}
selected := applySelection(items, newSel) selected := applySelection(items, newSel)
if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil { if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil {
@@ -99,6 +104,9 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change
} }
fmt.Fprintf(stdout, "source %s: selection updated at pinned rev %s (mode %s)\n", name, short(src.Rev), newSel.Mode) fmt.Fprintf(stdout, "source %s: selection updated at pinned rev %s (mode %s)\n", name, short(src.Rev), newSel.Mode)
if len(newSel.ExcludeTypes) > 0 {
fmt.Fprintf(stdout, " type filter: excluding %s\n", strings.Join(newSel.ExcludeTypes, ", "))
}
printChangeReport(stdout, report) printChangeReport(stdout, report)
return report, nil return report, nil
} }
@@ -106,30 +114,43 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change
// planSelection derives the new selection from the flags — pure, before any // planSelection derives the new selection from the flags — pure, before any
// upstream contact: --mode all resets to everything; include mode grows and // upstream contact: --mode all resets to everything; include mode grows and
// shrinks the include list; all mode --remove moves ids onto the exclude // shrinks the include list; all mode --remove moves ids onto the exclude
// list. // list. --types/--exclude-types replace the type filter (absent flags keep
// it; --mode all clears it along with include/exclude).
func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockfile.Selection, error) { func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockfile.Selection, error) {
typeFilter, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes)
if err != nil {
return lockfile.Selection{}, err
}
if opts.ModeAll { if opts.ModeAll {
if len(opts.Add) > 0 || len(opts.Remove) > 0 { if len(opts.Add) > 0 || len(opts.Remove) > 0 || typeFilter != nil {
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add or --remove") return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add, --remove, --types or --exclude-types")
} }
return lockfile.Selection{Mode: lockfile.ModeAll}, nil return lockfile.Selection{Mode: lockfile.ModeAll}, nil
} }
if len(opts.Add) == 0 && len(opts.Remove) == 0 { if len(opts.Add) == 0 && len(opts.Remove) == 0 && typeFilter == nil {
return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids> or --mode all") return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids>, --types/--exclude-types or --mode all")
} }
var newSel lockfile.Selection
if src.Selection.Mode == lockfile.ModeAll { if src.Selection.Mode == lockfile.ModeAll {
if len(opts.Add) > 0 { if len(opts.Add) > 0 {
return lockfile.Selection{}, fmt.Errorf("source %q: everything is already selected in all mode; use --remove <ids> to exclude items", name) return lockfile.Selection{}, fmt.Errorf("source %q: everything is already selected in all mode; use --remove <ids> to exclude items", name)
} }
return lockfile.Selection{ newSel = lockfile.Selection{
Mode: lockfile.ModeAll, Mode: lockfile.ModeAll,
Exclude: unionIDs(src.Selection.Exclude, opts.Remove), Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
}, nil
} }
return lockfile.Selection{ } else {
newSel = lockfile.Selection{
Mode: lockfile.ModeInclude, Mode: lockfile.ModeInclude,
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add), Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
}, nil }
}
if typeFilter != nil {
newSel.ExcludeTypes = typeFilter
} else {
newSel.ExcludeTypes = src.Selection.ExcludeTypes
}
return newSel, nil
} }
// validateSelectIDs checks flag ids against the pinned upstream (and, for // validateSelectIDs checks flag ids against the pinned upstream (and, for
@@ -137,7 +158,7 @@ func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockf
// before anything is mutated. // before anything is mutated.
func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error { func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error {
if len(opts.Add) > 0 { if len(opts.Add) > 0 {
if err := checkIncludeEntries(items, opts.Add, name); err != nil { if err := checkIDEntries(items, opts.Add, name, "include"); err != nil {
return fmt.Errorf("--add: %w", err) return fmt.Errorf("--add: %w", err)
} }
} }
@@ -153,7 +174,7 @@ func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts Select
} }
} }
if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeAll { if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeAll {
if err := checkIncludeEntries(items, opts.Remove, name); err != nil { if err := checkIDEntries(items, opts.Remove, name, "exclude"); err != nil {
return fmt.Errorf("--remove: %w", err) return fmt.Errorf("--remove: %w", err)
} }
} }
+6 -1
View File
@@ -197,7 +197,7 @@ func computeUpdate(workDir string, lf *lockfile.Lockfile, name string, withColli
} }
if src.Selection.Mode == lockfile.ModeInclude { if src.Selection.Mode == lockfile.ModeInclude {
if err := checkIncludeEntries(items, src.Selection.Include, name); err != nil { if err := checkIDEntries(items, src.Selection.Include, name, "include"); err != nil {
return nil, err return nil, err
} }
} }
@@ -250,6 +250,11 @@ func cleanupStaleStaging(workDir string) {
} }
func materializeTree(tree discovery.Tree, selected []discovery.Item, renames map[string]string, dst string) error { func materializeTree(tree discovery.Tree, selected []discovery.Item, renames map[string]string, dst string) error {
// Create dst even for an empty selection: the caller swaps it in with a
// rename, which fails when nothing materialized the directory.
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
for _, it := range selected { for _, it := range selected {
dep := deployedName(it.UpstreamID, renames) dep := deployedName(it.UpstreamID, renames)
base := filepath.Join(dst, it.Type) base := filepath.Join(dst, it.Type)
+78
View File
@@ -81,6 +81,26 @@ assert_no_file "$WR3/external" "no partial external area"
assert_file "$WR3/external/sp/skills/sp-good/SKILL.md" "renamed folder carries deployed name" assert_file "$WR3/external/sp/skills/sp-good/SKILL.md" "renamed folder carries deployed name"
assert_no_file "$WR3/external/sp/skills/good-skill" "upstream name absent after rename" assert_no_file "$WR3/external/sp/skills/good-skill" "upstream name absent after rename"
step "vendor add selection flags (--exclude, --types)"
WR4="$WORK/work-repo-4"
mkdir -p "$WR4"
(cd "$WR4" && "$BIN" vendor add cur "$UP" --exclude broken-skill) > "$WORK/exclude.log" 2>&1
assert_exit_zero $? "add --exclude succeeds"
assert_no_file "$WR4/external/cur/skills/broken-skill" "excluded skill absent"
assert_file "$WR4/external/cur/skills/good-skill/SKILL.md" "non-excluded skill present"
assert_contains "$WR4/agent-lib.lock.json" '"exclude": [' "exclude list pinned"
WR5="$WORK/work-repo-5"
mkdir -p "$WR5"
(cd "$WR5" && "$BIN" vendor add typed "$UP" --types skills,commands) > "$WORK/types.log" 2>&1
assert_exit_zero $? "add --types succeeds"
assert_no_file "$WR5/external/typed/agents" "agent type filtered out"
assert_no_file "$WR5/external/typed/mcp" "mcp type filtered out"
assert_contains "$WR5/agent-lib.lock.json" '"exclude_types": [' "type filter pinned"
set +e
(cd "$WR5" && "$BIN" vendor add bad "$UP" --include good-skill --exclude broken-skill) > "$WORK/flagconflict.log" 2>&1; RCX=$?
set -e
assert_exit_nonzero "$RCX" "--include + --exclude rejected"
step "validate / list / inspect" step "validate / list / inspect"
(cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1 (cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1
assert_exit_zero $? "healthy state validates" assert_exit_zero $? "healthy state validates"
@@ -114,6 +134,15 @@ assert_no_file "$WR/external/superpowers" "remove leaves no residue"
(cd "$WR" && "$BIN" vendor add superpowers "$UP") >/dev/null 2>&1 (cd "$WR" && "$BIN" vendor add superpowers "$UP") >/dev/null 2>&1
git -C "$WR" add -A && git -C "$WR" commit -qm "vendor superpowers (publication = commit && push)" git -C "$WR" add -A && git -C "$WR" commit -qm "vendor superpowers (publication = commit && push)"
step "vendor select type filter parity"
(cd "$WR5" && "$BIN" vendor select typed --exclude-types skills) > "$WORK/types-select.log" 2>&1
assert_exit_zero $? "select --exclude-types succeeds"
assert_no_file "$WR5/external/typed/skills" "skills dropped after narrowing the filter"
assert_contains "$WORK/types-select.log" "type filter" "select reports the type filter"
(cd "$WR5" && "$BIN" vendor select typed --mode all) > "$WORK/types-reset.log" 2>&1
assert_exit_zero $? "select --mode all clears the filter"
assert_file "$WR5/external/typed/skills/good-skill/SKILL.md" "skills restored after --mode all"
step "employee sync" step "employee sync"
HOME_DIR="$WORK/employee-home" HOME_DIR="$WORK/employee-home"
STATE="$WORK/employee-state" STATE="$WORK/employee-state"
@@ -142,6 +171,31 @@ assert_exit_zero "$RC2" "second sync succeeds"
printf '%s' "$OUT2" > "$WORK/sync2.log" printf '%s' "$OUT2" > "$WORK/sync2.log"
assert_contains "$WORK/sync2.log" "kept:" "second run keeps everything" assert_contains "$WORK/sync2.log" "kept:" "second run keeps everything"
step "default branch stays fresh without ref"
DB="$WORK/db-repo"
mkdir -p "$DB/skills/db-skill"
git -C "$DB" init -q -b main
printf -- '---\nname: DB\n---\n# v1\n' > "$DB/skills/db-skill/SKILL.md"
git -C "$DB" add -A && git -C "$DB" commit -qm "db v1"
DBCFG="$WORK/db-config.json"
printf '{"repo_url":"%s"}\n' "$DB" > "$DBCFG"
DBHOME="$WORK/db-home"; DBSTATE="$WORK/db-state"
mkdir -p "$DBHOME" "$DBSTATE"
set +e
HOME="$DBHOME" USERPROFILE="$DBHOME" AGENT_LIB_STATE_DIR="$DBSTATE" \
"$BIN" sync --config "$DBCFG" >/dev/null 2>&1; RCDB=$?
set -e
assert_exit_zero "$RCDB" "first default-branch sync (clone)"
printf -- '---\nname: DB\n---\n# v2\n' > "$DB/skills/db-skill/SKILL.md"
git -C "$DB" add -A && git -C "$DB" commit -qm "db v2"
set +e
HOME="$DBHOME" USERPROFILE="$DBHOME" AGENT_LIB_STATE_DIR="$DBSTATE" \
"$BIN" sync --config "$DBCFG" > "$WORK/db2.log" 2>&1; RCDB2=$?
set -e
assert_exit_zero "$RCDB2" "second default-branch sync (fetch)"
assert_contains "$WORK/db2.log" "updated: 1" "default branch advanced past the clone-time revision"
grep -q "# v2" "$DBHOME/.agents/skills/db-skill/SKILL.md" && ok "fresh content deployed" || fail "stale clone-time content served"
step "protected sync: local modification wins" step "protected sync: local modification wins"
printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md" printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md"
printf -- '---\nname: Own Skill\ndescription: v2\n---\n# Upstream v2\n' > "$WR/skills/own-skill/SKILL.md" printf -- '---\nname: Own Skill\ndescription: v2\n---\n# Upstream v2\n' > "$WR/skills/own-skill/SKILL.md"
@@ -153,6 +207,18 @@ assert_contains "$WORK/sync3.log" "skipped (local modifications kept): 1" "modif
grep -q "MY EDITS" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "local version kept" || fail "local version overwritten" grep -q "MY EDITS" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "local version kept" || fail "local version overwritten"
assert_contains "$STATE/sync.log" "SKIP skills/own-skill" "warning logged with item and reason" assert_contains "$STATE/sync.log" "SKIP skills/own-skill" "warning logged with item and reason"
step "forced sync: repository version wins"
set +e
OUTF="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$STATE" \
"$BIN" sync --config "$CFG" --force 2>&1)"; RCF=$?
set -e
assert_exit_zero "$RCF" "sync --force succeeds"
printf '%s' "$OUTF" > "$WORK/syncf.log"
assert_contains "$WORK/syncf.log" "updated:" "forced overwrite reported as update"
grep -q "Upstream v2" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "repository version restored" || fail "local modification survived --force"
# re-introduce drift so the upstream-deletion walk below has a modified copy
printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md"
step "self-created item survives" step "self-created item survives"
MINE="$HOME_DIR/.agents/skills/my-private-skill" MINE="$HOME_DIR/.agents/skills/my-private-skill"
mkdir -p "$MINE" && printf -- '---\nname: Mine\n---\n' > "$MINE/SKILL.md" mkdir -p "$MINE" && printf -- '---\nname: Mine\n---\n' > "$MINE/SKILL.md"
@@ -189,6 +255,18 @@ set +e; OUTSJ="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$
assert_exit_nonzero "$RCSJ" "status --json keeps drift exit code" assert_exit_nonzero "$RCSJ" "status --json keeps drift exit code"
printf '%s' "$OUTSJ" | grep -q '"problematic_drift": true' && ok "json drift flag set" || fail "json drift flag missing" printf '%s' "$OUTSJ" | grep -q '"problematic_drift": true' && ok "json drift flag set" || fail "json drift flag missing"
step "forced sync after upstream deletion removes modified copies"
set +e
OUTR="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$STATE" \
"$BIN" sync --config "$CFG" --force 2>&1)"; RCR=$?
set -e
assert_exit_zero "$RCR" "sync --force after deletion succeeds"
printf '%s' "$OUTR" > "$WORK/syncr.log"
assert_contains "$WORK/syncr.log" "removed:" "forced removals reported"
assert_no_file "$HOME_DIR/.agents/skills/own-skill" "modified skill removed by force"
assert_no_file "$HOME_DIR/.config/opencode/agents/scout.md" "modified agent removed by force"
assert_file "$MINE/SKILL.md" "unmanaged item survives --force untouched"
step "unreachable repository keeps state" step "unreachable repository keeps state"
BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")" BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")"
printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG" printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG"