Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e7d693699 | ||
|
|
a913baa496 | ||
|
|
0c779bc970 | ||
|
|
1ab97bff43 | ||
|
|
2017329015 |
No files matched your search
+15
-9
@@ -3,10 +3,21 @@ version: 2
|
||||
project_name: agent-lib
|
||||
|
||||
# Releases gehen an die eigene Gitea-Instanz (NICHT GitHub):
|
||||
# GITEA_URL=https://code.m3ta.dev
|
||||
# GITEA_TOKEN=<token mit repository-write>
|
||||
# Repo/Owner werden aus dem origin-Remote abgeleitet
|
||||
# (gitea@code.m3ta.dev:m3tam3re/agent-lib.git).
|
||||
# GITEA_TOKEN=<token mit repository-write> als Umgebungsvariable
|
||||
# Owner/Name sind explizit gesetzt, weil das SCP-Stil-Remote
|
||||
# (gitea@code.m3ta.dev:m3tam3re/agent-lib.git) fuer die automatische
|
||||
# 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:
|
||||
hooks:
|
||||
@@ -43,11 +54,6 @@ archives:
|
||||
# .../releases/download/<tag>/agent-lib_<os>_<arch>.<ext>
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
release:
|
||||
draft: false
|
||||
prerelease: auto
|
||||
mode: append
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
|
||||
@@ -89,11 +89,23 @@ executed.
|
||||
- Default: vendor **everything** (`mode: all`).
|
||||
- `--include a,b,c` switches the source to **include mode**: only those ids
|
||||
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`
|
||||
and `validate`:
|
||||
- `all` combined with an include list → error
|
||||
- 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
|
||||
[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 --remove review,debug # include mode: shrink
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
supports exclude in all mode) and deletes their folders; `--add` is a hard
|
||||
error — everything is already selected.
|
||||
- `--mode all` clears include/exclude and re-materializes the full pinned
|
||||
upstream, reporting what appeared.
|
||||
- `--types` / `--exclude-types` **replace** the content-type filter
|
||||
(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
|
||||
— 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 & 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:
|
||||
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
|
||||
|
||||
@@ -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: … 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
func newSyncCmd() *cobra.Command {
|
||||
var configPath, repoURL, ref string
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
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, " +
|
||||
"or from the config chain: --config, " + client.ConfigEnv + " env, the " +
|
||||
"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,
|
||||
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 {
|
||||
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(&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().BoolVar(&force, "force", false, "overwrite locally modified managed items with the repository version; unmanaged items are never touched")
|
||||
return cmd
|
||||
}
|
||||
+30
-14
@@ -26,7 +26,7 @@ func newVendorCmd() *cobra.Command {
|
||||
|
||||
func newVendorAddCmd() *cobra.Command {
|
||||
var ref string
|
||||
var include []string
|
||||
var include, exclude, onlyTypes, excludeTypes []string
|
||||
var renames []string
|
||||
cmd := &cobra.Command{
|
||||
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" +
|
||||
"agents and MCP fragments, materializes the selection under external/<name>/\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.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -43,11 +46,14 @@ func newVendorAddCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
opts := vendor.AddOptions{
|
||||
Name: args[0],
|
||||
URL: args[1],
|
||||
Ref: ref,
|
||||
Include: splitList(include),
|
||||
Renames: renameMap,
|
||||
Name: args[0],
|
||||
URL: args[1],
|
||||
Ref: ref,
|
||||
Include: splitList(include),
|
||||
Exclude: splitList(exclude),
|
||||
OnlyTypes: splitList(onlyTypes),
|
||||
ExcludeTypes: splitList(excludeTypes),
|
||||
Renames: renameMap,
|
||||
}
|
||||
if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil {
|
||||
return fmt.Errorf("vendor add: %w", err)
|
||||
@@ -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().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)")
|
||||
return cmd
|
||||
}
|
||||
@@ -75,6 +84,7 @@ func splitList(in []string) []string {
|
||||
|
||||
func newVendorSelectCmd() *cobra.Command {
|
||||
var add, remove []string
|
||||
var onlyTypes, excludeTypes []string
|
||||
var mode string
|
||||
cmd := &cobra.Command{
|
||||
Use: "select <name>",
|
||||
@@ -83,9 +93,11 @@ func newVendorSelectCmd() *cobra.Command {
|
||||
"Include mode: --add appends ids (validated against the pinned upstream),\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" +
|
||||
"--mode all resets to everything: include/exclude lists are cleared and the\n" +
|
||||
"full pinned upstream is re-materialized. Collisions re-run on every change;\n" +
|
||||
"renames from the lockfile are honored. The revision never moves — that is\n" +
|
||||
"--types/--exclude-types replace the content-type filter (skills, commands,\n" +
|
||||
"agents, mcp); absent flags keep it as-is. --mode all resets to everything:\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.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -93,9 +105,11 @@ func newVendorSelectCmd() *cobra.Command {
|
||||
return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode)
|
||||
}
|
||||
opts := vendor.SelectOptions{
|
||||
Add: splitList(add),
|
||||
Remove: splitList(remove),
|
||||
ModeAll: mode == "all",
|
||||
Add: splitList(add),
|
||||
Remove: splitList(remove),
|
||||
ModeAll: mode == "all",
|
||||
OnlyTypes: splitList(onlyTypes),
|
||||
ExcludeTypes: splitList(excludeTypes),
|
||||
}
|
||||
if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil {
|
||||
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(&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
|
||||
}
|
||||
@@ -47,8 +47,11 @@ type PlanEntry struct {
|
||||
// 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
|
||||
// touches the filesystem or the environment. The plan is sorted by
|
||||
// (type, name) for deterministic execution and reporting.
|
||||
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string) []PlanEntry {
|
||||
// (type, name) for deterministic execution and reporting. With force set,
|
||||
// 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{}
|
||||
for _, it := range items {
|
||||
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, "")
|
||||
}
|
||||
case inInv && it.Hash == rec.Hash:
|
||||
decideAgainstDisk(it, rec, fact, emit, "unchanged")
|
||||
decideAgainstDisk(it, rec, fact, emit, "unchanged", force)
|
||||
case inInv:
|
||||
decideAgainstDisk(it, rec, fact, emit, "changed")
|
||||
decideAgainstDisk(it, rec, fact, emit, "changed", force)
|
||||
case rec != nil && !fact.Exists:
|
||||
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:
|
||||
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:
|
||||
emit(ActSkip, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin},
|
||||
"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
|
||||
}
|
||||
|
||||
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 {
|
||||
case !fact.Exists:
|
||||
emit(ActUpdate, it, "managed item missing on disk; restoring")
|
||||
@@ -121,6 +126,8 @@ func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit fu
|
||||
} else {
|
||||
emit(ActUpdate, it, "")
|
||||
}
|
||||
case force:
|
||||
emit(ActUpdate, it, "forced: locally modified copy overwritten")
|
||||
default:
|
||||
emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")")
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestPlanMatrix(t *testing.T) {
|
||||
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 len(plan) != 0 {
|
||||
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
|
||||
// full deterministic output.
|
||||
func TestPlanMixedFixture(t *testing.T) {
|
||||
@@ -151,7 +199,7 @@ func TestPlanMixedFixture(t *testing.T) {
|
||||
"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{}
|
||||
for _, e := range plan {
|
||||
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}})
|
||||
disk := map[string]DiskFact{"skills/b": {Exists: true, Hash: hashV1}}
|
||||
|
||||
p1 := Plan(items, manifest, disk, testPathFor)
|
||||
p2 := Plan(items, manifest, disk, testPathFor)
|
||||
p1 := Plan(items, manifest, disk, testPathFor, false)
|
||||
p2 := Plan(items, manifest, disk, testPathFor, false)
|
||||
if len(p1) != len(p2) {
|
||||
t.Fatal("same inputs must yield same plan length")
|
||||
}
|
||||
|
||||
+46
-1
@@ -31,7 +31,15 @@ func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// re-point takes effect on the next sync.
|
||||
func repointRemote(repo *git.Repository, url string) error {
|
||||
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
|
||||
// SyncOptions parameterize a client sync run. RepoURL (with optional Ref)
|
||||
// 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 {
|
||||
ConfigPath string
|
||||
RepoURL string
|
||||
Ref string
|
||||
Force bool
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 {
|
||||
if !safePath(e.DstPath) {
|
||||
return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,15 @@ const (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
Mode SelectionMode `json:"mode"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
Mode SelectionMode `json:"mode"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
ExcludeTypes []string `json:"exclude_types,omitempty"`
|
||||
}
|
||||
|
||||
// Source pins one vendored git source.
|
||||
@@ -140,6 +144,7 @@ func (l *Lockfile) Save(path string) error {
|
||||
for _, src := range l.Sources {
|
||||
sort.Strings(src.Selection.Exclude)
|
||||
sort.Strings(src.Selection.Include)
|
||||
sort.Strings(src.Selection.ExcludeTypes)
|
||||
sort.Strings(src.Warnings)
|
||||
for _, items := range src.Inventory {
|
||||
sort.Strings(items)
|
||||
|
||||
Vendored
+117
-12
@@ -21,11 +21,14 @@ const externalDir = "external"
|
||||
|
||||
// AddOptions parameterize vendor add.
|
||||
type AddOptions struct {
|
||||
Name string
|
||||
URL string
|
||||
Ref string
|
||||
Include []string
|
||||
Renames map[string]string
|
||||
Name string
|
||||
URL string
|
||||
Ref string
|
||||
Include []string
|
||||
Exclude []string
|
||||
OnlyTypes []string
|
||||
ExcludeTypes []string
|
||||
Renames map[string]string
|
||||
}
|
||||
|
||||
// Add vendors a new source into the work repository at workDir: it clones,
|
||||
@@ -68,12 +71,29 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
sel := lockfile.Selection{Mode: lockfile.ModeAll}
|
||||
if len(opts.Include) > 0 {
|
||||
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include}
|
||||
if err := checkIncludeEntries(items, opts.Include, opts.Name); err != nil {
|
||||
if len(opts.Include) > 0 && len(opts.Exclude) > 0 {
|
||||
return fmt.Errorf("--include and --exclude cannot be combined (a v2 selection is either include mode or all mode minus excludes)")
|
||||
}
|
||||
excludeTypes, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes)
|
||||
if err != nil {
|
||||
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)
|
||||
@@ -147,21 +167,96 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery {
|
||||
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{}
|
||||
for _, it := range items {
|
||||
if !it.Skipped {
|
||||
available[it.UpstreamID] = true
|
||||
}
|
||||
}
|
||||
for _, want := range include {
|
||||
for _, want := range ids {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
exclude := map[string]bool{}
|
||||
for _, e := range sel.Exclude {
|
||||
@@ -171,11 +266,18 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.
|
||||
for _, i := range sel.Include {
|
||||
include[i] = true
|
||||
}
|
||||
excludedTypes := map[string]bool{}
|
||||
for _, t := range sel.ExcludeTypes {
|
||||
excludedTypes[t] = true
|
||||
}
|
||||
var out []discovery.Item
|
||||
for _, it := range items {
|
||||
if it.Skipped {
|
||||
continue
|
||||
}
|
||||
if excludedTypes[it.Type] {
|
||||
continue
|
||||
}
|
||||
switch sel.Mode {
|
||||
case lockfile.ModeInclude:
|
||||
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, "discovered %d items (%s)\n", real, counts(discovered))
|
||||
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))
|
||||
if len(src.Warnings) > 0 {
|
||||
fmt.Fprintf(w, "warnings:\n")
|
||||
|
||||
Vendored
+37
-16
@@ -15,9 +15,11 @@ import (
|
||||
|
||||
// SelectOptions parameterize vendor select.
|
||||
type SelectOptions struct {
|
||||
Add []string
|
||||
Remove []string
|
||||
ModeAll bool
|
||||
Add []string
|
||||
Remove []string
|
||||
ModeAll bool
|
||||
OnlyTypes []string
|
||||
ExcludeTypes []string
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkIncludeVsTypes(items, newSel, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selected := applySelection(items, newSel)
|
||||
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)
|
||||
if len(newSel.ExcludeTypes) > 0 {
|
||||
fmt.Fprintf(stdout, " type filter: excluding %s\n", strings.Join(newSel.ExcludeTypes, ", "))
|
||||
}
|
||||
printChangeReport(stdout, report)
|
||||
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
|
||||
// upstream contact: --mode all resets to everything; include mode grows and
|
||||
// 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) {
|
||||
typeFilter, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes)
|
||||
if err != nil {
|
||||
return lockfile.Selection{}, err
|
||||
}
|
||||
if opts.ModeAll {
|
||||
if len(opts.Add) > 0 || len(opts.Remove) > 0 {
|
||||
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add or --remove")
|
||||
if len(opts.Add) > 0 || len(opts.Remove) > 0 || typeFilter != nil {
|
||||
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add, --remove, --types or --exclude-types")
|
||||
}
|
||||
return lockfile.Selection{Mode: lockfile.ModeAll}, nil
|
||||
}
|
||||
if len(opts.Add) == 0 && len(opts.Remove) == 0 {
|
||||
return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids> or --mode all")
|
||||
if len(opts.Add) == 0 && len(opts.Remove) == 0 && typeFilter == nil {
|
||||
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 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{
|
||||
newSel = lockfile.Selection{
|
||||
Mode: lockfile.ModeAll,
|
||||
Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
|
||||
}, nil
|
||||
}
|
||||
} else {
|
||||
newSel = lockfile.Selection{
|
||||
Mode: lockfile.ModeInclude,
|
||||
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
|
||||
}
|
||||
}
|
||||
return lockfile.Selection{
|
||||
Mode: lockfile.ModeInclude,
|
||||
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
|
||||
@@ -137,7 +158,7 @@ func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockf
|
||||
// before anything is mutated.
|
||||
func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 err := checkIncludeEntries(items, opts.Remove, name); err != nil {
|
||||
if err := checkIDEntries(items, opts.Remove, name, "exclude"); err != nil {
|
||||
return fmt.Errorf("--remove: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
-1
@@ -197,7 +197,7 @@ func computeUpdate(workDir string, lf *lockfile.Lockfile, name string, withColli
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,11 @@ func cleanupStaleStaging(workDir string) {
|
||||
}
|
||||
|
||||
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 {
|
||||
dep := deployedName(it.UpstreamID, renames)
|
||||
base := filepath.Join(dst, it.Type)
|
||||
|
||||
@@ -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_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"
|
||||
(cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1
|
||||
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
|
||||
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"
|
||||
HOME_DIR="$WORK/employee-home"
|
||||
STATE="$WORK/employee-state"
|
||||
@@ -142,6 +171,31 @@ assert_exit_zero "$RC2" "second sync succeeds"
|
||||
printf '%s' "$OUT2" > "$WORK/sync2.log"
|
||||
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"
|
||||
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"
|
||||
@@ -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"
|
||||
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"
|
||||
MINE="$HOME_DIR/.agents/skills/my-private-skill"
|
||||
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"
|
||||
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"
|
||||
BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")"
|
||||
printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG"
|
||||
|
||||
Reference in new issue
Block a user