Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion boatstack/autonomy_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(t *testing.T) {
}
compiled := filepath.Join(root, "compiled")
lockPath := filepath.Join(root, "plan.lock.json")
if err := ActivatePlan(ActivationOptions{PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil {
if err := ActivatePlan(ActivationOptions{Repo: root, PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil {
t.Fatal(err)
}
value, err := os.ReadFile(lockPath)
Expand Down
11 changes: 6 additions & 5 deletions boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,18 +497,18 @@ func checkPlanCommand(arguments []string) int {
if *plan == "" {
return fail(fmt.Errorf("check-plan requires --plan"))
}
check, err := boatstack.CheckPlan(*plan)
check, err := boatstack.CheckPlanForRepository(".", *plan)
if err != nil {
return fail(fmt.Errorf("invalid Markdown plan: %w", err))
}
baseline, err := boatstack.PlanningBaselineForPlan(*plan)
baseline, err := boatstack.PlanningBaselineForRepository(".", *plan)
if err != nil {
return fail(fmt.Errorf("cannot fingerprint the pre-activation product baseline: %w", err))
}
readinessFingerprint := ""
if version, _ := check.Plan["schema_version"].(float64); version >= 3 {
readiness, readinessErr := boatstack.CheckPlanReadiness(*plan)
repo, _ := boatstack.ResolveControllerRepository(filepath.Dir(*plan))
readiness, readinessErr := boatstack.CheckPlanReadinessForRepository(".", *plan)
repo, _ := boatstack.ResolveRepository(".")
if readinessErr != nil {
boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error())
return fail(readinessErr)
Expand Down Expand Up @@ -538,7 +538,7 @@ func checkSourcePlanCommand(arguments []string) int {

func activatePlanCommand(arguments []string) int {
flags := flag.NewFlagSet("activate-plan", flag.ContinueOnError)
options := boatstack.ActivationOptions{}
options := boatstack.ActivationOptions{Repo: "."}
flags.StringVar(&options.PlanPath, "plan", "", "approved Markdown plan")
flags.StringVar(&options.ApprovalPath, "approval", "", "Markdown approval receipt")
flags.StringVar(&options.OutDir, "out-dir", "", "compiled artifact directory")
Expand Down Expand Up @@ -635,6 +635,7 @@ func recordApprovalCommand(arguments []string) int {
return fail(fmt.Errorf("record-approval requires --plan, --approved-by, --approved-at, and --fingerprint"))
}
if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{
Repo: ".",
PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy,
ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256,
ExpectedLifecycleSHA256: *expectedLifecycleSHA256, ExpectedPlanLockSHA256: *expectedPlanLockSHA256,
Expand Down
2 changes: 1 addition & 1 deletion boatstack/config_event_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func TestConfigurationEventRegistryIsComplete(t *testing.T) {
}
sort.Strings(entries)
digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
const expected = "ed524f110b7ade6e3a5795c5f26ee20db87153a0c91865c8e7cd63a9ee133f0f"
const expected = "72481d05df0ef6d80a42b62f0e917ac173c436c970ed3f1aaaa8430aa41911a2"
if digest != expected {
_ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644)
t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest)
Expand Down
7 changes: 4 additions & 3 deletions boatstack/delivery_reactivation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ func TestValidateAmendmentPreservesProgressBoundaries(t *testing.T) {
func TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer(t *testing.T) {
existing := publishedThenBuilding("MERGED")
newSlices := []DeliverySlice{
{ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def
{ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice
{ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice
{ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def
{ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice
{ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice
}

result := reconcileAmendedDeliveryState(existing, newSlices, "new-lock")
Expand Down Expand Up @@ -169,6 +169,7 @@ func reactivateWithAmendedPlan(t *testing.T, repo, feature string, mutate func(p
approvalPath := filepath.Join(dir, "approval.md")
writeApprovalReceipt(t, approvalPath, check.Fingerprint)
return ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: planPath,
ApprovalPath: approvalPath,
OutDir: filepath.Join(dir, "compiled"),
Expand Down
2 changes: 2 additions & 0 deletions boatstack/delivery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func activateTwoSliceDeliveryConfigured(t *testing.T, maintainChangelog bool, co
approvalPath := filepath.Join(directory, "approval.md")
writeApprovalReceipt(t, approvalPath, check.Fingerprint)
if err := ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(directory, "compiled"),
OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
}); err != nil {
Expand Down Expand Up @@ -265,6 +266,7 @@ func TestDeliveryGateReceiptsBindTheActiveSliceAndAdvanceOnce(t *testing.T) {
}
directory := filepath.Join(repo, ".product-loop", "features", feature)
if err := ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"),
OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
Expand Down
15 changes: 11 additions & 4 deletions boatstack/detached_external_config_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@ func filesystemSnapshot(t *testing.T, root string) string {
t.Helper()
entries := []string{}
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
relative, relativeErr := filepath.Rel(root, path)
if relativeErr != nil {
return relativeErr
}
// git maintenance creates and removes this advisory lock independently of
// the operation under test. It is not repository content and may disappear
// between WalkDir reading the directory and lstatting the entry on macOS.
if filepath.ToSlash(relative) == ".git/objects/maintenance.lock" {
return nil
}
if walkErr != nil {
return walkErr
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
info, err := entry.Info()
if err != nil {
return err
Expand Down Expand Up @@ -274,6 +280,7 @@ func TestDetachedConfigDriftBlocksMutationAndPublicationBypasses(t *testing.T) {
}{
{name: "activation", run: func() error {
return ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: planPath, OutDir: filepath.Join(ctx.FeatureDir("feature-one"), "compiled"),
OutputPath: filepath.Join(ctx.FeatureDir("feature-one"), "plan.lock.json"), SourceCommit: "test",
})
Expand Down
1 change: 1 addition & 0 deletions boatstack/detached_ownership_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ func TestDetachedActivationUsesCanonicalFeatureDirectory(t *testing.T) {
t.Fatalf("resolved owner lost detached context: resolved=%s ctx=%+v err=%v", resolved, ctx, ctxErr)
}
err := ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: filepath.Join(directory, "plan.md"), OutDir: filepath.Join(directory, "compiled"),
OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: "test",
})
Expand Down
3 changes: 3 additions & 0 deletions boatstack/lifecycle_authority_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
t.Fatal(err)
}
if err := RecordApproval(ApprovalRecordOptions{
Repo: repo,
PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-08-10T12:00:00Z",
Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256,
ExpectedLifecycleSHA256: drafted.Fingerprint, ExpectedPlanLockSHA256: drafted.PlanLockSHA256,
Expand All @@ -137,6 +138,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
t.Fatal(err)
}
if err := ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"),
OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
Expand All @@ -148,6 +150,7 @@ func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
t.Fatal(err)
}
if err := ActivatePlan(ActivationOptions{
Repo: repo,
PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"),
OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
Expand Down
2 changes: 1 addition & 1 deletion boatstack/lifecycle_event_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestLifecycleEventRegistryIsComplete(t *testing.T) {
}
sort.Strings(entries)
digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
const expected = "2b6d3a0ba8b53513e744b478949da352e932aa2998b796f8077efaf286a14c0f"
const expected = "167ba36844018e8eefe5120416c1354714dbc888588a96369cc2c83457ce433b"
if digest != expected {
t.Fatalf("lifecycle event registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n"))
}
Expand Down
31 changes: 30 additions & 1 deletion boatstack/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package boatstack
import (
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
)
Expand Down Expand Up @@ -180,21 +181,49 @@ func ResolveControllerRepository(path string) (string, error) {
if err != nil {
return "", err
}
var matches []string
for repo := range registry.Repositories {
ctx, ok, _ := detachedContextFor(repo)
if !ok {
continue
}
if pathWithin(ctx.ExportRoot(), path) {
return repo, nil
matches = append(matches, repo)
}
}
sort.Strings(matches)
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return "", fmt.Errorf("controller path has multiple verified repository aliases; supply the invoking repository explicitly: %s (%s)", path, strings.Join(matches, ", "))
}
if repo, err := ResolveRepository(path); err == nil {
return repo, nil
}
return "", fmt.Errorf("path is not owned by a repository or verified detached controller: %s", path)
}

// ResolveControllerRepositoryFor validates a controller path against an
// explicit invoking repository. Detached controller roots are intentionally
// shared by aliases of one Git repository, so effectful operations must carry
// the caller's worktree identity forward instead of reconstructing it from the
// non-injective controller path.
func ResolveControllerRepositoryFor(repoPath, path string) (string, error) {
repo, err := ResolveRepository(repoPath)
if err != nil {
return "", err
}
ctx, err := ResolveWorkspaceContext(repo)
if err != nil {
return "", err
}
if !pathWithin(ctx.ExportRoot(), path) {
return "", fmt.Errorf("controller path is not owned by the invoking repository %s: %s", repo, path)
}
return repo, nil
}

var (
workspaceCacheMu sync.Mutex
workspaceCache = map[string]WorkspaceContext{}
Expand Down
Loading