Documentation
¶
Overview ¶
Package sync owns the `tai sync` capability: cloning the configured source repo into <TAI_DATA_DIR>/source/, fetching updates, copying assets into configured targets with M1 existence-based overwrite detection, manifest tracking, and the --prune deletion path. The background update-poll goroutine consumed by the update-banner capability lives in poll.go in the same package.
Normative spec: openspec/changes/pivot-to-ai-as-code/specs/repo-sync/spec.md.
Index ¶
- func AutoInstallForTesting(t testing.TB, fn AutoInstallFn)
- func CloneDir(dataDir string) string
- func EmitBanner(stderr io.Writer, dataDir string, now time.Time)
- func EnsureClone(ctx context.Context, dataDir, repoURL string) (string, error)
- func Fetch(ctx context.Context, cloneDir string) error
- func JoinRel(base, rel string) string
- func LastFetchSuccess(cloneDir string) time.Time
- func LatestPrefixedTagForTesting(t testing.TB, fn LatestPrefixedTagFn)
- func LatestTagForTesting(t testing.TB, fn LatestTagFn)
- func ManifestPath(dataDir, root string) string
- func NoticeCancelled(stderr io.Writer)
- func NoticeOrphans(stderr io.Writer, count int)
- func NoticeOverwritten(stderr io.Writer, overwrites map[Category][]string)
- func Poll(ctx context.Context, cfg *config.File, dataDir string) error
- func Prompt(p *Plan, confirmDelete bool, stdin io.Reader, stderr io.Writer) (bool, error)
- func SaveManifest(dataDir, root string, m *Manifest) error
- func SaveState(dataDir string, s PollState) error
- func SourceFiles(cloneDir string, cat Category) ([]string, error)
- func StatePath(dataDir string) string
- func TargetSubpath(root string, override *string, defaultName string) string
- type AutoInstallFn
- type Category
- type LatestPrefixedTagFn
- type LatestTagFn
- type Manifest
- type Options
- type Plan
- type PluginUpdate
- type PollState
- type Result
- type Waiter
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AutoInstallForTesting ¶
func AutoInstallForTesting(t testing.TB, fn AutoInstallFn)
AutoInstallForTesting swaps the auto-install function for the lifetime of t. The strict default is restored via t.Cleanup so every test starts from a clean state.
`testing.TB` makes accidental production use a code-review red flag — the only path to call this is from a `_test.go` file or a binary that deliberately imports `testing`.
func CloneDir ¶
CloneDir returns the absolute path to TAI's single source-repo clone. Spec: "The system SHALL maintain exactly one git clone of the configured source repo, in TAI's data directory at <TAI_DATA_DIR>/source/. The location is not configurable."
func EmitBanner ¶
EmitBanner reads the poll state at dataDir, emits a banner to stderr when the once-per-day gate allows, and persists the new last-banner-date back to the state file.
Returns silently when there is nothing to do (no state file, no pending updates, banner already fired today). All errors are best-effort absorbed — a missing or unreadable state file is the spec's "first-ever invocation" branch.
The `now` parameter is the wall-clock used to compute today's calendar day. Production passes time.Now(); tests substitute a frozen instant so the banner-suppression assertion is deterministic.
func EnsureClone ¶
EnsureClone clones the source repo on first call and reuses the existing clone on subsequent calls. The fetch step is separate (see Fetch) so callers can decide whether to ignore fetch failures and fall back to the cached state.
Returns the clone directory on success. The error is an *errcode.Error{Code: REPO_FETCH_FAILED} when the initial `git clone` fails — there is no cache to fall back to on the very first sync.
func Fetch ¶
Fetch runs `git fetch` followed by `git reset --hard origin/<default-branch>` so the workspace reflects the upstream tip. On failure (network, auth, etc.) Fetch returns the wrapped error without modifying the clone — callers decide whether to fall back to the cached state with a warning.
Every returned error is `*errcode.Error{Code: REPO_FETCH_FAILED}` so the contract stays uniform across the fetch step and the reset step. Today Sync swallows the error and emits a stderr warning, but a future direct caller (e.g. a unit test on Fetch) can rely on the stable error type.
"Last successful fetch" timestamps come from os.Stat on <clone>/.git/FETCH_HEAD (git updates that file's mtime on each successful fetch). LastFetchAttempt provides the lookup helper.
func JoinRel ¶
JoinRel joins a base directory with a forward-slash-normalised relative path. Mirrors filepath.Join but accepts the sorted-slash form returned by SourceFiles without leaking forward-slashes onto Windows.
func LastFetchSuccess ¶
LastFetchSuccess returns the mtime of <clone>/.git/FETCH_HEAD. The zero time signals "no successful fetch on record" (FETCH_HEAD does not exist yet), which only happens between the initial clone and the first explicit fetch — the clone itself does not touch FETCH_HEAD, so we use the directory's own mtime as a fallback.
func LatestPrefixedTagForTesting ¶
func LatestPrefixedTagForTesting(t testing.TB, fn LatestPrefixedTagFn)
LatestPrefixedTagForTesting swaps the prefix-aware lookup function for the lifetime of t. Parallel to LatestTagForTesting.
func LatestTagForTesting ¶
func LatestTagForTesting(t testing.TB, fn LatestTagFn)
LatestTagForTesting swaps the latest-tag function for the lifetime of t. Mirrors the testing-bypass pattern used by config.AllowFileURLsForTesting and sync.AutoInstallForTesting.
func ManifestPath ¶
ManifestPath returns the absolute path to the manifest file for the target rooted at root. The filename is a hex sha256 of the root so "~/.claude" and "/Users/dan/.claude" can't accidentally share a manifest after path expansion.
func NoticeCancelled ¶
NoticeCancelled writes a one-line cancellation message to stderr for the "user answered N" path.
func NoticeOrphans ¶
NoticeOrphans writes the "N orphans pending — run `tai sync --prune`" summary to stderr. Called on every sync (per the spec) when orphans exist and --prune was NOT supplied.
func NoticeOverwritten ¶
NoticeOverwritten writes (when -y is in play and overwrites happened) a brief stderr summary so the user can see what got touched.
func Poll ¶
Poll performs one synchronous update-check against the configured source repo and writes the result into <TAI_DATA_DIR>/state/ update-check.json. Errors are returned but callers MUST swallow them in production — see Schedule below for the fire-and-forget shape.
Skips the work (returns nil) when:
- cfg is nil or has no repo-url (no source to check)
- interval is 0 (poll disabled)
- the cache is fresh (within interval since last check)
In each skip case the state file is NOT modified.
func Prompt ¶
Prompt prints the batched plan to stderr and reads a y/N response from stdin. Returns true iff the user confirms.
Sync calls this with confirmDelete=true ONLY when --prune is on and orphans are present; without --prune the orphans-pending summary goes through a separate non-blocking call (NoticeOrphans).
When -y is set, callers MUST short-circuit BEFORE calling Prompt; this function unconditionally reads stdin.
func SaveManifest ¶
SaveManifest serialises m to its canonical path atomically. The on-disk representation sorts entries alphabetically and lives in JSON-with-pretty-print so PR diffs stay readable when the manifest is checked into git (not the default, but supported for review workflows).
Write is tempfile-then-rename to match SaveState's pattern: two concurrent `tai sync` runs writing the same target's manifest can no longer leave it half-written.
func SaveState ¶
SaveState writes the poll state file atomically (rename-over to avoid torn writes when two TAI invocations race).
We pre-clean any stale `<path>.tmp` left over by a previously-killed goroutine — without this, repeated invocations where the goroutine is reaped mid-write would slowly accumulate orphaned tmp files in the state directory.
func SourceFiles ¶
SourceFiles walks <cloneDir>/<category> and returns every file relative to that subdirectory. Directories themselves are not returned. The slice is sorted lexically so two runs against the same tree produce byte-identical output (deterministic prompts).
Returns an empty slice (not nil) when the category subdir does not exist in the clone — a source repo without skills is valid; tai just has nothing to copy under that bucket.
func StatePath ¶
StatePath is the canonical location of the poll state file. Lives under <TAI_DATA_DIR>/state/ so plugin state can coexist alongside without collision.
func TargetSubpath ¶
TargetSubpath joins root + the effective sub-path for cat. Returns an empty string when the sub-path is falsy ("skip this category") — callers MUST treat an empty result as "do nothing for this category".
Inputs:
- root: the target's root (already resolved to an absolute path by the caller).
- override: the target's per-category override pointer from the config (nil = default, "" = skip, value = literal override).
- defaultName: the canonical sub-path name (skills/commands/agents) used when override is nil.
Types ¶
type AutoInstallFn ¶
type AutoInstallFn func(ctx context.Context, name, dataDir string, cfg *config.File, opts plugins.InstallOptions) (*plugins.Entry, error)
AutoInstallFn is the signature for the auto-install seam tests override via AutoInstallForTesting. Production binds it to plugins.Install directly; tests substitute a no-network stub that records the call.
type Category ¶
type Category string
Category is one of the three asset buckets tai recognises. The values are the canonical source-side subdirectory names; the effective target-side name is overridable per-target in config.
func Categories ¶
func Categories() []Category
Categories returns the three buckets in fixed order. Tests rely on this order to be stable so batched prompts read top-down.
type LatestPrefixedTagFn ¶
LatestPrefixedTagFn is the signature of the prefix-aware lookup the plugin-row layer calls. Production binds it to httpLatestPrefixedTag (which wraps plugins.LatestPrefixedTag with the package's timeout-bounded HTTP client). Tests substitute a stub via LatestPrefixedTagForTesting.
Return convention: ("", nil) is the "no matching stable release" sentinel; the caller MUST treat it as "no update available", NOT as an error. Real lookup failures return (_, non-nil) and the caller absorbs them per the spec.
type LatestTagFn ¶
LatestTagFn is the signature of the function Poll uses to query GitHub Releases for a `<host>/<repo>`'s latest tag. Production binds it to httpLatestTag; tests substitute a stub.
type Manifest ¶
type Manifest struct {
// Paths is the unsorted set of installed entries. The exported
// form is a map so contains/add/delete operations stay O(1); on
// disk we serialise as a sorted slice for diff-friendly output.
Paths map[string]bool `json:"-"`
}
Manifest is the per-target record of every relative path tai has installed and not yet pruned. The file lives at <TAI_DATA_DIR>/manifests/<sha256-of-target-root>.json and MUST NOT be inside any target directory (the user might wipe their target; the manifest survives so orphan detection keeps working).
The entries map's keys are relative paths in the form "<category>/<rel>" — e.g. "skills/triage-comments.md". This carries enough context for orphan deletion across all three categories without needing a separate manifest per category.
func LoadManifest ¶
LoadManifest reads the manifest file for root. Returns an empty (non-nil) Manifest when the file does not exist — "no manifest yet" is the first-sync state.
type Options ¶
type Options struct {
// Yes (the -y / --yes flag) bypasses the overwrite/prune
// confirmation prompt.
Yes bool
// Prune (--prune) instructs Sync to delete orphans (entries in
// the manifest no longer present in the current source). Without
// it, orphans persist and are surfaced in the summary.
Prune bool
// Stdin / Stdout / Stderr — Sync's I/O. Tests pass buffers /
// strings.NewReader; main.go passes os.Std*.
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
Options carries the per-invocation flags Sync respects.
type Plan ¶
type Plan struct {
// Overwrites groups paths that already exist at the destination
// by category. Each entry is "<target-root>/<effective-subpath>/<rel>".
Overwrites map[Category][]string
// Orphans is the union of orphan entries (per category, but
// represented as "<category>/<rel>" for table simplicity) across
// all targets. Caller stuffs absolute paths into PrunePaths for
// the actual delete walk.
Orphans []string
// PrunePaths carries the absolute filesystem paths the deletion
// pass walks when --prune is on and the user confirms.
PrunePaths []string
}
Plan groups every category's would-create / would-overwrite paths across all configured targets, plus the orphan list. The prompt renders this batched view in a single user-visible block.
func (*Plan) HasOrphans ¶
HasOrphans reports whether the plan would touch any orphan path. Distinct from "has orphans pending" because --prune is the only path that actually deletes; without it, orphans count but no confirmation is required.
func (*Plan) HasOverwrites ¶
HasOverwrites reports whether any category has a non-empty list.
type PluginUpdate ¶
type PluginUpdate struct {
// Name is the plugin's directory-name identity, matching the
// state entry in plugins.json.
Name string `json:"name"`
// Current is the version currently installed (from
// plugins.json's entry.Version at poll time).
Current string `json:"current"`
// Latest is the most recent release tag the poll observed. The
// banner fires for this plugin when Latest != Current AND both
// are non-empty. When the HTTP query for the plugin's source
// fails, the row is omitted from PollState.Plugins entirely
// (rather than written with Latest == Current) so the next
// poll retries cleanly per the spec.
Latest string `json:"latest"`
}
PluginUpdate is one row in PollState.Plugins describing the installed-vs-available version for a single plugin.
type PollState ¶
type PollState struct {
// LastCheck is when the poll completed. Drives the next-run
// staleness decision in IsStale.
LastCheck time.Time `json:"last-check"`
// LastBannerDate is the calendar day (local TZ, formatted
// YYYY-MM-DD) the banner most recently fired. The banner is
// suppressed when this equals today.
LastBannerDate string `json:"last-banner-date,omitempty"`
// Source-repo layer (Phase 2 — unchanged).
LocalCommit string `json:"local-commit,omitempty"`
RemoteCommit string `json:"remote-commit,omitempty"`
// HasUpdates is true when LocalCommit and RemoteCommit differ.
// Preserved for backwards compatibility with the Phase 2 tests.
HasUpdates bool `json:"has-updates"`
// TAI-itself layer (Phase 5). Empty strings mean "not checked
// this poll" — either the version was "dev" (local build) or
// the HTTP query failed.
TAICurrent string `json:"tai-current,omitempty"`
TAILatest string `json:"tai-latest,omitempty"`
// Installed-plugins layer (Phase 5). One entry per installed
// plugin whose latest tag was successfully queried.
Plugins []PluginUpdate `json:"plugins,omitempty"`
}
PollState records the result of the most recent background update-check poll across all three update layers: TAI itself, every installed plugin, and the configured source repo. The struct is the JSON shape written to <TAI_DATA_DIR>/state/update-check.json and consumed by the update-banner.
func LoadState ¶
LoadState reads the poll state file. Returns (zero-value, nil) when the file does not yet exist — first-ever poll, banner has nothing to say.
func (PollState) HasPendingUpdate ¶
HasPendingUpdate reports whether any layer has a pending update. Used by the banner gate. Guards every layer against the empty- string-corruption case (a state file with one of the fields missing) so a partial cache never spuriously triggers the banner.
type Result ¶
type Result struct {
// Written is the count of files written (created or overwritten)
// across all targets and categories.
Written int
// Overwritten is the subset of Written that already existed.
Overwritten int
// OrphansPending is the count of manifest entries that no longer
// exist in the source. Reported regardless of --prune.
OrphansPending int
// Pruned is the count of orphan files actually deleted from
// targets (--prune + confirm only).
Pruned int
// Cancelled is true when the user answered N at the prompt.
Cancelled bool
}
Result describes what Sync did. The fields are deliberately per-category and per-target-flat (a single []string) so tests can assert without re-walking the filesystem.
func Sync ¶
Sync performs the full sync flow for every configured target. The flow per the spec:
- EnsureClone — clones on first sync, reuses otherwise.
- Fetch — eager fetch; on failure emits one-line cache-fallback warning to stderr but does NOT abort.
- For each target: walk the source under each category, classify destinations as create / overwrite / up-to-date, load the manifest, compute orphans.
- If any overwrite/orphan needs confirmation and -y is unset, emit one batched prompt to stderr and read stdin.
- Write the planned source files, update the manifest, and (when --prune is on and confirmed) delete orphans.
Pre-conditions enforced before any I/O: both repo-url and at least one target MUST be configured. Otherwise Sync exits with TAI_NOT_CONFIGURED before touching disk.
type Waiter ¶
type Waiter struct {
// contains filtered or unexported fields
}
Schedule fires Poll on a background goroutine and returns a *Waiter the caller uses to wait briefly (or detach) at exit time.
Production main.go invokes this near startup with the configured values and calls Waiter.Wait(timeout) before exiting. Tests can call Poll synchronously instead.
func Schedule ¶
Schedule starts the background poll. Returns the Waiter immediately; the goroutine runs concurrently with the foreground command.