diff --git a/docs/agents.md b/docs/agents.md index 75ac320..bf0b73b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -218,7 +218,7 @@ the hub. See [Set up a hub's platform](guide/setup.md). | `probe` | jobs any hub branch can start | — | exits 2 when the job can see a write credential or signing key | | `setup github|gitlab [--dry-run] ...` | a maintainer's machine | `TOUCHMARK_HUB_TOKEN` | creates the reader and writer, stores the keys so only the default branch sees the write key, protects the hub; changes only what differs; exit 3 when a step is left to you | | `migrate --from-multi-gitter FILE [--id ID] [--writer LOGIN]` | anywhere | reader, optional | prints `hub.yml`, `targets.yml` and `operations.yml` for a hub that replaces a multi-gitter setup on GitLab | -| `status [--hub DIR] [--dir DIR] [--repo REF] [--packs a,b]` | a target checkout | none | lists every managed path and its state; changes nothing | +| `status [--hub DIR] [--dir DIR] [--repo REF] [--packs a,b] [--assume-opt-in]` | a target checkout | none | lists every managed path and its state; changes nothing | | `apply [status flags] [--adopt GLOB]... [--dry-run]` | a target checkout | none | applies the packs to the working tree; `--adopt` takes `local` files back | | `manifest [--hub DIR]` | hub | none | prints the ownership manifest as JSON | | `schema hub|targets|opt-in|operations|report|doctor|setup` | anywhere | none | prints a JSON Schema | diff --git a/docs/guide/local.md b/docs/guide/local.md index 3199251..5c0ec72 100644 --- a/docs/guide/local.md +++ b/docs/guide/local.md @@ -53,7 +53,8 @@ touchmark apply --hub ~/src/engineering-assets --adopt '.agents/guidelines/**' ``` `--adopt` takes a glob and repeats. `ignore` in the opt-in file is stronger: an ignored -path is never adopted. +path is never adopted. In a repository the hub subscribes through an `org:` or `group:` +entry with `opt_in: assumed`, and that has no opt-in file, add `--assume-opt-in` (below). ## Organisations, groups and explicit packs @@ -77,9 +78,18 @@ without the opt-in file: - without `--repo` or an `origin` remote, touchmark cannot tell, and warns when `targets.yml` subscribes anything. -An opt-in file in the checkout settles it either way; with `enabled: false` in it, -`status` prints `opted out` and `apply` writes nothing. In `--format json` the target's -`opt_in` says which: `file`, `assumed`, `opted-out` or `none`. +`--assume-opt-in` settles the last two cases here: a target without the opt-in file +counts as opted in, as such an entry would make it. `status` prints `opted in by +--assume-opt-in`, and `apply` writes the packs of `targets.yml`: + +```sh +touchmark apply --hub ~/src/engineering-assets --assume-opt-in --adopt AGENTS.md +``` + +An opt-in file in the checkout settles it either way, whatever the flag; with +`enabled: false` in it, `status` prints `opted out` and `apply` writes nothing. In +`--format json` the target's `opt_in` says which: `file`, `assumed`, `flag` (opted in +by `--assume-opt-in`), `opted-out` or `none`. ## Try a pack before committing it diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 14bf5d2..910c11c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -238,11 +238,12 @@ Flags: ```text -Usage: touchmark status [--hub DIR] [--dir DIR] [--repo REF] [--packs a,b] [--worktree] [--format text|json] +Usage: touchmark status [--hub DIR] [--dir DIR] [--repo REF] [--packs a,b] [--assume-opt-in] [--worktree] [--format text|json] List every managed path of the target and its state; changes nothing. Flags: + --assume-opt-in count the target as opted in when it has no opt-in file, as an org or group entry with opt_in: assumed that a local run cannot resolve would --dir DIR the target working tree DIR (default: the current directory) --format FORMAT output FORMAT: text or json (default text) --hub DIR the hub checkout DIR (default $TOUCHMARK_HUB) @@ -264,6 +265,7 @@ Apply the packs to the target's working tree. Flags: --adopt GLOB overwrite local files matching GLOB with the pack version (repeatable) + --assume-opt-in count the target as opted in when it has no opt-in file, as an org or group entry with opt_in: assumed that a local run cannot resolve would --dir DIR the target working tree DIR (default: the current directory) --dry-run print what apply would change without writing --format FORMAT output FORMAT: text or json (default text) diff --git a/docs/reference/output.md b/docs/reference/output.md index d0494c8..b164229 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -271,7 +271,8 @@ appears in it. See [Set up a hub's platform](../guide/setup.md). `--format json` prints the hub (its directory, `id` and commit), the target (its directory, its reference in `targets.yml`, its opt-in file, whether it opted in, and `opt_in`, which says why: `file`, the opt-in file is there; `assumed`, there is none and -a `repo:` entry with `opt_in: assumed` subscribes it; `opted-out`, the file says +a `repo:` entry with `opt_in: assumed` subscribes it; `flag`, there is none and +`--assume-opt-in` counts it as opted in; `opted-out`, the file says `enabled: false`; `none`, neither), the pack selection and where each pack came from, one entry per managed path with its `state`, `action`, `pack` and blob ids, a `summary` counting each state, and `warnings`: diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 5e5f892..c609b54 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -93,7 +93,7 @@ func commands() []*command { flags: probeFlags, run: runProbe}, migrateCommand(), setupCommand(), - {name: "status", synopsis: "[--hub DIR] [--dir DIR] [--repo REF] [--packs a,b] [--worktree] [--format text|json]", + {name: "status", synopsis: "[--hub DIR] [--dir DIR] [--repo REF] [--packs a,b] [--assume-opt-in] [--worktree] [--format text|json]", summary: "list every managed path of the target and its state; changes nothing", flags: statusFlags, run: runStatus}, {name: "apply", synopsis: "[status flags] [--adopt GLOB]... [--dry-run]", diff --git a/internal/cli/flags.go b/internal/cli/flags.go index d29fce4..605223b 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -31,6 +31,8 @@ type options struct { worktree bool dryRun bool format string + // assumeOptIn is status's and apply's --assume-opt-in (target.assume). + assumeOptIn bool // only, hubFP and strict are plan's (plan.go). only refList hubFP fingerprintFlag @@ -169,6 +171,8 @@ func (f *flagSet) targetFlags() { f.fs.StringVar(&f.o.dir, "dir", "", "the target working tree `DIR` (default: the current directory)") f.fs.StringVar(&f.o.repo, "repo", "", "the target in targets.yml, as `OWNER/NAME` or PROVIDER:PATH (default: from the origin remote)") f.fs.Var(&f.o.packs, "packs", "comma-separated `PACKS` to apply instead of the selection from targets.yml and the opt-in file") + f.fs.BoolVar(&f.o.assumeOptIn, "assume-opt-in", false, + "count the target as opted in when it has no opt-in file, as an org or group entry with opt_in: assumed that a local run cannot resolve would") } func checkFlags() (*flagSet, *options) { diff --git a/internal/cli/sync.go b/internal/cli/sync.go index ae0bdfa..0ae13a3 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -111,7 +111,7 @@ func (s *syncRun) load(ctx context.Context) error { if err := t.resolveRef(ctx, s.o.repo, h); err != nil { return err } - if err := t.assume(h); err != nil { + if err := t.assume(h, s.o.assumeOptIn); err != nil { return err } s.hub, s.tgt = h, t diff --git a/internal/cli/target.go b/internal/cli/target.go index 40ca3b0..9ce71b5 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -113,30 +113,43 @@ func (t *target) readOptIn(name string) error { // It then gets the packs of targets.yml, as if it had an empty opt-in // file. An org or group entry with opt_in: assumed that may hold the // target is a warning: only the platform tells whether it selects it, so -// the target stays not opted in here, and plan has the answer. -func (t *target) assume(h *hub) error { +// the target stays not opted in here, and plan has the answer. flag +// (--assume-opt-in) settles that here instead: the target counts as +// opted in, as such an entry would make it. An opt-in file, enabled: +// false included, still wins over the flag. +func (t *target) assume(h *hub, flag bool) error { if t.optInState != report.OptInNone { return nil } + var warning string if !t.refKnown { if h.targets != nil && (h.targets.Defaults.OptIn == config.OptInAssumed || slices.ContainsFunc(h.targets.Targets, func(e config.Entry) bool { return e.OptIn == config.OptInAssumed })) { - t.warnings = append(t.warnings, "targets.yml subscribes some repositories without an opt-in file (opt_in: assumed), "+ - "and this one's name is unknown (no --repo and no origin remote): pass --repo to tell whether it is one of them") + warning = "targets.yml subscribes some repositories without an opt-in file (opt_in: assumed), " + + "and this one's name is unknown (no --repo and no origin remote): pass --repo to tell whether it is one of them, " + + "or --assume-opt-in to count it as one" + } + } else { + a, err := config.Assume(h.cfg, h.targets, t.ref) + if err != nil { + return configError(err) + } + if a.Assumed { + t.optIn, t.optInState = &config.OptIn{Version: 1}, report.OptInAssumed + return nil + } + if len(a.Unresolved) > 0 { + warning = fmt.Sprintf("targets.yml may subscribe this repository without an opt-in file: %s with opt_in: assumed "+ + "cannot be resolved here, so it counts as not opted in; plan tells, and --assume-opt-in or an opt-in file decides it here", + strings.Join(a.Unresolved, ", ")) } - return nil - } - a, err := config.Assume(h.cfg, h.targets, t.ref) - if err != nil { - return configError(err) } - if a.Assumed { - t.optIn, t.optInState = &config.OptIn{Version: 1}, report.OptInAssumed + if flag { + t.optIn, t.optInState = &config.OptIn{Version: 1}, report.OptInFlag return nil } - if len(a.Unresolved) > 0 { - t.warnings = append(t.warnings, fmt.Sprintf("targets.yml may subscribe this repository without an opt-in file: %s with opt_in: assumed "+ - "cannot be resolved here, so it counts as not opted in; plan tells, and an opt-in file decides it here", strings.Join(a.Unresolved, ", "))) + if warning != "" { + t.warnings = append(t.warnings, warning) } return nil } diff --git a/internal/cli/targets_test.go b/internal/cli/targets_test.go index d2fa0d0..73a0569 100644 --- a/internal/cli/targets_test.go +++ b/internal/cli/targets_test.go @@ -49,6 +49,28 @@ func TestAssumedOptIn(t *testing.T) { if rep := unknown.report("status"); rep.Target.OptedIn || len(rep.Warnings) != 1 || !strings.Contains(rep.Warnings[0], "pass --repo") { t.Errorf("a target without a name: %+v, warnings %q", rep.Target, rep.Warnings) } + + // --assume-opt-in settles what the org entry leaves open: the library + // counts as opted in, with the packs of targets.yml and no warning, and + // apply writes them. So does a target without a name. + libY := newRepo(t, false) + flagged := newScenario(t, "assumed", h, libY, "--repo", "acme/lib-y", "--assume-opt-in") + flagged.golden("status-flag.txt", flagged.text("status")) + if rep := flagged.report("status"); !rep.Target.OptedIn || rep.Target.OptIn != report.OptInFlag || len(rep.Warnings) != 0 { + t.Errorf("acme/lib-y with --assume-opt-in: %+v, warnings %q", rep.Target, rep.Warnings) + } + flagged.json("apply") + if tree := libY.tree(); !strings.Contains(tree, "AGENTS.md") { + t.Errorf("apply --assume-opt-in wrote no packs:\n%s", tree) + } + unnamed := newScenario(t, "assumed", h, newPlainDir(t), "--assume-opt-in") + if rep := unnamed.report("status"); !rep.Target.OptedIn || rep.Target.OptIn != report.OptInFlag || len(rep.Warnings) != 0 { + t.Errorf("a target without a name, with --assume-opt-in: %+v, warnings %q", rep.Target, rep.Warnings) + } + // A repo: entry that subscribes the target still says so. + if rep := newScenario(t, "assumed", h, newRepo(t, false), "--repo", "acme/svc", "--assume-opt-in").report("status"); rep.Target.OptIn != report.OptInAssumed { + t.Errorf("acme/svc with --assume-opt-in: opt_in %q, want %q", rep.Target.OptIn, report.OptInAssumed) + } } // An opt-in file with enabled: false opts the target out, whatever @@ -62,6 +84,10 @@ func TestOptedOut(t *testing.T) { s := newScenario(t, "opted-out", h, tg, "--repo", "acme/svc") s.golden("status.txt", s.text("status")) s.golden("apply.json", s.json("apply")) + if rep := newScenario(t, "opted-out", h, tg, "--repo", "acme/svc", "--assume-opt-in").report("status"); rep.Target.OptedIn || + rep.Target.OptIn != report.OptInDisabled { + t.Errorf("enabled: false with --assume-opt-in: %+v", rep.Target) + } if tree := tg.tree(); strings.Contains(tree, "AGENTS.md") { t.Errorf("apply wrote into a target that opted out:\n%s", tree) } diff --git a/internal/cli/testdata/golden/assumed/status-flag.txt b/internal/cli/testdata/golden/assumed/status-flag.txt new file mode 100644 index 0000000..3edf923 --- /dev/null +++ b/internal/cli/testdata/golden/assumed/status-flag.txt @@ -0,0 +1,10 @@ +touchmark status · hub acme-eng @ $COMMIT · target acme/lib-y +opted in by --assume-opt-in: .engineering-assets.yml not found in $TARGET +packs: base (defaults) + + create missing .github/pull_request_template.md base + create missing AGENTS.md base + create missing docs/guide.md base + +missing 3 +3 changes to apply: run touchmark apply diff --git a/internal/report/report.go b/internal/report/report.go index 0b3f6bd..55c1bf8 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -36,8 +36,8 @@ type Target struct { // when it could not be determined. Ref string `json:"ref"` OptInFile string `json:"opt_in_file"` - // OptedIn is set when the target gets packs: OptIn is OptInFile or - // OptInAssumed. + // OptedIn is set when the target gets packs: OptIn is OptInFile, + // OptInAssumed or OptInFlag. OptedIn bool `json:"opted_in"` // OptIn says why the target is opted in or not: one of the OptIn* // values. @@ -51,6 +51,10 @@ const ( // OptInAssumed: the target has no opt-in file, and a repo: entry of // targets.yml with opt_in: assumed subscribes it. OptInAssumed = "assumed" + // OptInFlag: the target has no opt-in file, and --assume-opt-in counts + // it as opted in, as an org or group entry with opt_in: assumed that + // a local run cannot resolve would. + OptInFlag = "flag" // OptInDisabled: the opt-in file says enabled: false. OptInDisabled = "opted-out" // OptInNone: the target has no opt-in file, and no repo: entry diff --git a/internal/report/text.go b/internal/report/text.go index f61167d..87edd4c 100644 --- a/internal/report/text.go +++ b/internal/report/text.go @@ -64,6 +64,8 @@ func (s *Sync) WriteText(w io.Writer) error { return p.err case s.Target.OptIn == OptInAssumed: p.linef("opted in by targets.yml (opt_in: assumed): %s not found in %s", s.Target.OptInFile, s.Target.Root) + case s.Target.OptIn == OptInFlag: + p.linef("opted in by --assume-opt-in: %s not found in %s", s.Target.OptInFile, s.Target.Root) } if s.Selection != nil { p.linef("packs: %s", packsLine(s.Selection))