Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
363c19f
feat(secret): add git credential type
michaelmcnees Apr 18, 2026
49d47e4
feat(audit): add repo.{added,updated,removed} actions
michaelmcnees Apr 18, 2026
0966f80
feat(db): add 019_git_repos migration
michaelmcnees Apr 18, 2026
0578835
feat(repo): add Repo struct and validators
michaelmcnees Apr 18, 2026
a468afb
feat(repo): add Store.Create with audit emission
michaelmcnees Apr 18, 2026
31141ab
feat(repo): add Store.Get with nullable last-sync scan
michaelmcnees Apr 18, 2026
cc033f7
feat(repo): add Store.List
michaelmcnees Apr 18, 2026
2d917be
feat(repo): add Store.Update for mutable fields
michaelmcnees Apr 18, 2026
78f8006
feat(repo): add Store.Delete with audit emission
michaelmcnees Apr 18, 2026
7097c7b
feat(config): add git_sync.repos block
michaelmcnees Apr 18, 2026
a4503d3
feat(cli): add mantle repos root command
michaelmcnees Apr 18, 2026
11340d0
feat(cli): add mantle repos add
michaelmcnees Apr 18, 2026
ac04366
feat(cli): add mantle repos list
michaelmcnees Apr 18, 2026
495dcc7
feat(cli): add mantle repos status
michaelmcnees Apr 18, 2026
afa1d46
feat(cli): add mantle repos remove
michaelmcnees Apr 18, 2026
ab59e26
docs: document mantle repos CLI and git_sync config
michaelmcnees Apr 18, 2026
431f822
fix: address pre-push reviewer feedback for git sync foundation
michaelmcnees Apr 18, 2026
dc42f41
fix(repo): reject any userinfo in URL, not just passwords
michaelmcnees Apr 19, 2026
f64e1d6
fix(repo): include last_sync_* and webhook_secret in Update RETURNING…
michaelmcnees Apr 19, 2026
ec6f0cc
fix(repo): preserve existing Branch/Path in Update when caller passes…
michaelmcnees Apr 19, 2026
1d123a3
fix(migration): add ON DELETE RESTRICT to git_repos.team_id FK
michaelmcnees Apr 19, 2026
fcad25d
fix(config): validate git_sync.repos entries in Load
michaelmcnees Apr 19, 2026
def990f
fix(migration): rework git_repos enabled index to composite (team_id,…
michaelmcnees Apr 20, 2026
2d94a36
fix(cli): add Long+Example to repos status/list; move repos to admin …
michaelmcnees Apr 20, 2026
d52114b
test: cover Path/PollInterval/Prune in GitSync config test; add empty…
michaelmcnees Apr 20, 2026
41e85ac
refactor(test): tighten empty-state assertion; extract seedRepo helper
michaelmcnees Apr 20, 2026
be683a3
fix(repo): tighten validRepoNamePattern to reject trailing hyphens an…
michaelmcnees Apr 20, 2026
af33a5d
fix(config): inline git_sync validators to break test import cycle
michaelmcnees Apr 20, 2026
4a09c8e
fix(git-sync): address PR #133 review findings
claude Apr 24, 2026
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
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ mantle env update <name> # Replace inputs/env on an existing environment
mantle env list # List named environments
mantle env get <name> # Show environment details (env values redacted; --reveal to unredact, audited)
mantle env delete <name> -y # Delete a named environment (requires --yes)
mantle repos add <name> --url <url> --credential <cred> # Register a GitOps source repo
mantle repos list # List registered repos with last-sync status
mantle repos status <name> # Show detailed repo status
mantle repos remove <name> -y # Unregister a repo (requires --yes)
mantle run <wf> --values f.yaml # Run with a values file (inputs + env overrides)
mantle run <wf> --env <name> # Run against a stored named environment
mantle plan <wf> --env <name> # Plan; appends resolved inputs/env with source
Expand All @@ -91,6 +95,27 @@ mantle serve # Start persistent server
- **Workflow inputs** (consumed by `inputs.<name>` in CEL): `--input` flags > `--values` file `inputs:` > `--env` named-environment `inputs` > workflow definition `default`
- **Env vars** (consumed by `env.<KEY>` in CEL): `MANTLE_ENV_*` OS vars > `--values` file `env:` > `--env` named-environment `env` > config `env:` section in `mantle.yaml`

## GitOps Config

Register repos in `mantle.yaml` under `git_sync.repos`:

```yaml
git_sync:
repos:
- name: acme
url: https://github.com/acme/workflows.git
branch: main
path: /
poll_interval: 60s
credential: github-pat # must reference a secret of type: git
auto_apply: true
prune: true
```

Credentials of type `git` accept `token` (for HTTPS), `ssh_key` (for SSH), and optional `username`. At least one of `token` or `ssh_key` is required.

> Plan A ships the repo registry and CLI only. The sync engine that consumes this block — pulling from the sidecar, validating, and applying workflow YAML — ships in the next milestone. Registering a repo today has no runtime effect yet beyond persistence.

## License

BSL/SSPL-style — source available, no commercial resale of forks.
5 changes: 5 additions & 0 deletions packages/engine/internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ const (
ActionEnvironmentUpdated Action = "environment.updated"
ActionEnvironmentDeleted Action = "environment.deleted"
ActionEnvironmentRevealed Action = "environment.revealed"

// Git repo operations.
ActionRepoAdded Action = "repo.added"
ActionRepoUpdated Action = "repo.updated"
ActionRepoRemoved Action = "repo.removed"
)

// Resource identifies the target of an audit event.
Expand Down
211 changes: 211 additions & 0 deletions packages/engine/internal/cli/repos.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package cli

import (
"fmt"
"text/tabwriter"

"github.com/dvflw/mantle/internal/config"
"github.com/dvflw/mantle/internal/db"
"github.com/dvflw/mantle/internal/repo"
"github.com/spf13/cobra"
)

// newReposCommand returns the "repos" subcommand for managing registered
// GitOps source repositories (issue #16). Subcommands handle registration,
// listing, detailed status, and removal. Sync behavior lives in Plan B.
func newReposCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "repos",
Short: "Manage GitOps workflow source repositories",
Long: `Registers GitOps source repositories whose workflow YAML definitions will be
synced into this Mantle instance. This command manages the registry; the sync
engine itself (sidecar, file discovery, validate/plan/apply) ships in a later
milestone. Auth material is stored in a "git" credential type
(` + "`mantle secrets create --type git`" + `) and referenced here by name.`,
}
cmd.AddCommand(newReposAddCommand())
cmd.AddCommand(newReposListCommand())
cmd.AddCommand(newReposStatusCommand())
cmd.AddCommand(newReposRemoveCommand())
return cmd
}

// newRepoStore builds a repo.Store from the current command context.
func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) {
cfg := config.FromContext(cmd.Context())
if cfg == nil {
return nil, nil, fmt.Errorf("config not loaded")
}
database, err := db.Open(cfg.Database)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
}
store := &repo.Store{DB: database, Actor: "cli"}
cleanup := func() { database.Close() }
return store, cleanup, nil
}

func newReposAddCommand() *cobra.Command {
var url, branch, path, pollInterval, credential string
var autoApply, prune bool

cmd := &cobra.Command{
Use: "add <name>",
Short: "Register a new GitOps source repo",
Long: `Registers a new repository to sync workflow definitions from. The named
credential must already exist and be of type "git".`,
Example: ` mantle repos add acme --url https://github.com/acme/workflows.git --credential github-pat
mantle repos add staging --url git@github.com:acme/wf.git --credential github-ssh --branch release`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, cleanup, err := newRepoStore(cmd)
if err != nil {
return err
}
defer cleanup()

r, err := store.Create(cmd.Context(), repo.CreateParams{
Name: args[0],
URL: url,
Branch: branch,
Path: path,
PollInterval: pollInterval,
Credential: credential,
AutoApply: autoApply,
Prune: prune,
})
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Added repo %q (%s)\n", r.Name, r.ID)
return nil
},
}

cmd.Flags().StringVar(&url, "url", "", "Git repository URL (required)")
cmd.Flags().StringVar(&branch, "branch", "main", "Branch to sync")
cmd.Flags().StringVar(&path, "path", "/", "Subdirectory inside the repo to scan")
cmd.Flags().StringVar(&pollInterval, "poll-interval", "60s", "Interval between syncs (Go duration, min 10s)")
cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)")
cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes (false = plan-only)")
cmd.Flags().BoolVar(&prune, "prune", true, "When true, workflows deleted from the repo are disabled in Mantle")
_ = cmd.MarkFlagRequired("url")
_ = cmd.MarkFlagRequired("credential")

return cmd
}

func newReposStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status <name>",
Short: "Show detailed status for a registered repo",
Long: `Displays all persisted fields for a registered repository, including the
URL, branch, polling interval, credential reference, enable state, and the
outcome of the most recent sync attempt (SHA, timestamp, and any error).`,
Example: ` mantle repos status acme
mantle repos status staging --database-url postgres://localhost/mantle`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, cleanup, err := newRepoStore(cmd)
if err != nil {
return err
}
defer cleanup()

r, err := store.Get(cmd.Context(), args[0])
if err != nil {
return err
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "Name: %s\n", r.Name)
fmt.Fprintf(out, "ID: %s\n", r.ID)
fmt.Fprintf(out, "URL: %s\n", r.URL)
fmt.Fprintf(out, "Branch: %s\n", r.Branch)
fmt.Fprintf(out, "Path: %s\n", r.Path)
fmt.Fprintf(out, "Poll: %s\n", r.PollInterval)
fmt.Fprintf(out, "Credential: %s\n", r.Credential)
fmt.Fprintf(out, "Auto-Apply: %t\n", r.AutoApply)
fmt.Fprintf(out, "Prune: %t\n", r.Prune)
fmt.Fprintf(out, "Enabled: %t\n", r.Enabled)
if r.LastSyncAt != nil {
fmt.Fprintf(out, "Last Sync: %s (SHA %s)\n",
r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC"), r.LastSyncSHA)
} else {
fmt.Fprintln(out, "Last Sync: (never)")
}
if r.LastSyncError != "" {
fmt.Fprintf(out, "Last Error: %s\n", r.LastSyncError)
}
return nil
},
}
}

func newReposRemoveCommand() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "remove <name>",
Short: "Unregister a GitOps source repo",
Long: `Unregisters a repo. Any previously applied workflows remain in place — this
command only stops future syncs. Requires --yes to confirm.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !yes {
return fmt.Errorf("refusing to remove %q without --yes", args[0])
}
store, cleanup, err := newRepoStore(cmd)
if err != nil {
return err
}
defer cleanup()

if err := store.Delete(cmd.Context(), args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Removed repo %q\n", args[0])
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Confirm deletion (required)")
return cmd
}

func newReposListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all registered GitOps repos",
Long: `Prints a table of all repositories registered for the current team, ordered
by name. Columns include the repo URL, target branch, auto-apply flag, enable
state, and timestamp of the last successful sync.`,
Example: ` mantle repos list
mantle repos list --database-url postgres://localhost/mantle`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
store, cleanup, err := newRepoStore(cmd)
if err != nil {
return err
}
defer cleanup()

repos, err := store.List(cmd.Context())
if err != nil {
return err
}
if len(repos) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no repos)")
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tURL\tBRANCH\tAUTO-APPLY\tENABLED\tLAST SYNC")
for _, r := range repos {
last := "(never)"
if r.LastSyncAt != nil {
last = r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%t\t%s\n",
r.Name, r.URL, r.Branch, r.AutoApply, r.Enabled, last)
}
return w.Flush()
},
}
}
Loading
Loading