/**
* std/bump/live — local effects for the provider-neutral Harn bump runtime.
*
* This module owns filesystem, git, command, validation, and readiness polling.
* Remote repository behavior enters through one typed [`LiveBumpRemote`]
* capability. Provider packages implement that capability; stdlib never speaks
* REST, GraphQL, or a provider CLI and therefore cannot drift into a second
* connector implementation.
*
* All orchestration decisions remain in `std/bump/runtime`. Tests can replace
* the remote capability with a recording fake without a network or subprocess.
*/
import {
BumpAdapter,
BumpAutoMerge,
BumpBaseAdoption,
BumpBaseAdoptionRequest,
BumpBaseHead,
BumpChangeSet,
BumpCommitRequest,
BumpCommitResult,
BumpPrState,
BumpPrUpsert,
} from "std/bump/runtime"
import { command_run } from "std/command"
import { poll_until } from "std/poll"
import { is_v_semver, strip_v } from "std/semver"
/** Everything the local adapter needs for mutation, validation, and polling. */
pub type LiveBumpConfig = {
release_repo: string,
repo_dir: string,
version_file: string,
refresh_command: string,
validation_command: string,
readiness_max_attempts: int,
readiness_interval_ms: int,
readiness_timeout_ms: int,
}
/** Verdict of an authenticated fetch of the base branch into the local checkout. */
pub type LiveBumpFetch = {ok: bool, detail: string}
/** Closed release projection needed by the readiness gate. */
pub type LiveBumpRelease = {found: bool, draft: bool, prerelease: bool, asset_names: list<string>}
/**
* Provider package boundary for remote bump effects.
*
* One constructed instance is already bound to repository, branch, base, and
* authentication. The stdlib surface therefore exposes behavior rather than
* provider transport or configuration details.
*/
pub type LiveBumpRemote = {
latest_release_tag: fn() -> string,
release_view: fn(string) -> LiveBumpRelease,
base_head: fn() -> BumpBaseHead,
fetch_base: fn(string) -> LiveBumpFetch,
find_bump_pr: fn() -> BumpPrState?,
disable_auto_merge: fn(BumpPrState, string) -> bool,
publish_commit: fn(BumpCommitRequest) -> BumpCommitResult,
branch_matches_base: fn() -> bool,
upsert_pr: fn(BumpPrState?, string) -> BumpPrUpsert,
close_pr: fn(int, string) -> bool,
enable_auto_merge: fn(int, string) -> BumpAutoMerge,
}
/** Release finalization sidecars that prove a Harn release is fully published. */
const REQUIRED_RELEASE_ASSETS = ["SHA256SUMS", "release-assets.json"]
type VersionWriteResult = {ok: bool, detail: string}
type VersionApplyResult = {ok: bool, changed: bool, refreshed: list<string>, detail: string}
/**
* Fill a partial config with the reusable workflow defaults.
*
* @effects: []
* @errors: []
*/
pub fn live_bump_config(overrides: dict = {}) -> LiveBumpConfig {
const o = overrides ?? {}
return {
release_repo: to_string(o.release_repo ?? "burin-labs/harn"),
repo_dir: to_string(o.repo_dir ?? "."),
version_file: to_string(o.version_file ?? ".harn-version"),
refresh_command: to_string(o.refresh_command ?? "harn install --locked || harn install"),
validation_command: to_string(o.validation_command ?? ""),
readiness_max_attempts: to_int(o.readiness_max_attempts ?? 1) ?? 1,
readiness_interval_ms: to_int(o.readiness_interval_ms ?? 20000) ?? 20000,
readiness_timeout_ms: to_int(o.readiness_timeout_ms ?? 20000) ?? 20000,
}
}
fn __git(tools: HarnessTools, repo_dir: string, argv: list<string>) {
return command_run(tools, ["git", "-C", repo_dir] + argv)
}
fn __shell(tools: HarnessTools, repo_dir: string, command: string) {
return command_run(tools, {mode: "shell", command: command, cwd: repo_dir})
}
fn __lines(text: string?) -> list<string> {
const value = trim(to_string(text ?? ""))
return if value == "" {
[]
} else {
split(value, "\n").filter({ line -> trim(line) != "" }).to_list()
}
}
fn __working_changes(tools: HarnessTools, repo_dir: string) -> BumpChangeSet {
const tracked_additions = __lines(
__git(tools, repo_dir, ["diff", "--name-only", "--diff-filter=ACMRTUXB", "HEAD"]).stdout,
)
const untracked_additions = __lines(
__git(tools, repo_dir, ["ls-files", "--others", "--exclude-standard"]).stdout,
)
return {
additions: tracked_additions
+ untracked_additions.filter({ name -> !contains(tracked_additions, name) }).to_list(),
deletions: __lines(
__git(tools, repo_dir, ["diff", "--name-only", "--diff-filter=D", "HEAD"]).stdout,
),
}
}
/** Update the pin through typed filesystem authority, never shell redirection. */
fn __write_version(fs: HarnessFs?, path: string, version: string) -> VersionWriteResult {
if fs == nil {
return {
ok: false,
detail:
"std/bump/live: HarnessFs is required to update ${path}; pass harness.fs to live_bump_adapter",
}
}
const result = try {
fs.write_text(path, "${version}\n")
nil
}
return if is_ok(result) {
{ok: true, detail: ""}
} else {
{ok: false, detail: to_string(unwrap_err(result))}
}
}
fn __read_version(fs: HarnessFs?, path: string) -> string {
if fs == nil || !fs.exists(path) {
return ""
}
return trim(fs.read_text(path))
}
fn __apply_version(
fs: HarnessFs?,
tools: HarnessTools,
cfg: LiveBumpConfig,
target: string,
) -> VersionApplyResult {
const write = __write_version(fs, cfg.version_file, strip_v(target))
const refresh = if write.ok {
__shell(tools, cfg.repo_dir, cfg.refresh_command)
} else {
nil
}
const refresh_ok = refresh?.success ?? false
const changes = __working_changes(tools, cfg.repo_dir)
const refreshed = changes.additions + changes.deletions
return {
ok: write.ok && refresh_ok,
changed: len(refreshed) > 0,
refreshed: refreshed,
detail: if !write.ok {
"version-file write failed: " + write.detail
} else if refresh_ok {
"refresh ok"
} else {
trim((refresh?.stderr ?? "") + (refresh?.stdout ?? ""))
},
}
}
fn __adoption_failure(detail: string, conflicts: list<string> = []) -> BumpBaseAdoption {
return {ok: false, adopted_oid: nil, conflicts: conflicts, detail: detail}
}
/**
* Adopt an advanced base branch head into the local checkout.
*
* The provider capability owns the authenticated fetch; everything after it is
* local git. The adoption refuses before it mutates when the incoming commits
* changed a path this bump's refresh already authored: that is a contest
* between two writers of one artifact, and resetting would silently pick a
* winner. Otherwise the adapter discards only the refresh paths it owns and
* checks out the observed head detached, which is what
* `harn-github-connector` requires before it will derive a payload — it refuses
* any base oid that is not the local HEAD.
*/
fn __adopt_base(
fs: HarnessFs?,
tools: HarnessTools,
remote: LiveBumpRemote,
cfg: LiveBumpConfig,
request: BumpBaseAdoptionRequest,
) -> BumpBaseAdoption {
const to_oid = lowercase(trim(request.to_oid))
if to_oid == "" {
return __adoption_failure("no advanced base identity to adopt")
}
const fetch = remote.fetch_base(to_oid)
if !fetch.ok {
return __adoption_failure("fetching ${request.base} failed: ${fetch.detail}")
}
if !__git(tools, cfg.repo_dir, ["cat-file", "-e", "${to_oid}^{commit}"]).success {
return __adoption_failure(
"advanced base ${to_oid} is still not present after fetching ${request.base}",
)
}
const declared_from = lowercase(trim(request.from_oid ?? ""))
const from_oid = if declared_from != "" {
declared_from
} else {
lowercase(trim(__git(tools, cfg.repo_dir, ["rev-parse", "HEAD"]).stdout ?? ""))
}
const comparison = __git(tools, cfg.repo_dir, ["diff", "--name-only", "${from_oid}..${to_oid}"])
if !comparison.success {
return __adoption_failure(
"comparing ${from_oid} with ${to_oid} failed: "
+ trim((comparison.stderr ?? "") + (comparison.stdout ?? "")),
)
}
const incoming = __lines(comparison.stdout)
const authored = set(request.refreshed ?? [])
const conflicts = incoming.filter({ path -> set_contains(authored, path) }).to_list()
if len(conflicts) > 0 {
return __adoption_failure(
"${len(conflicts)} refreshed path(s) were also changed between ${from_oid} and ${to_oid}",
conflicts,
)
}
// Remove only untracked output the discarded refresh reported. Discovering
// the untracked intersection through git prevents tracked paths from being
// deleted, while typed filesystem deletion avoids an unrestricted git-clean
// capability. Running this before checkout leaves the tree on its proven
// base if cleanup fails.
const cleanup_paths = (request.refreshed ?? []).filter({ path -> path != "" }).to_list()
if len(cleanup_paths) > 0 {
const untracked_result = __git(
tools,
cfg.repo_dir,
["--literal-pathspecs", "ls-files", "--others", "--exclude-standard", "--"] + cleanup_paths,
)
if !untracked_result.success {
return __adoption_failure(
"discovering discarded refresh output failed: "
+ trim((untracked_result.stderr ?? "") + (untracked_result.stdout ?? "")),
)
}
const untracked = __lines(untracked_result.stdout)
if len(untracked) > 0 {
if fs == nil {
return __adoption_failure("cleaning discarded refresh output requires HarnessFs")
}
for path in untracked {
const deletion = try {
fs.delete(path_join(cfg.repo_dir, path))
nil
}
if !is_ok(deletion) {
return __adoption_failure(
"cleaning discarded refresh output failed: " + to_string(unwrap_err(deletion)),
)
}
}
}
const tracked_result = __git(
tools,
cfg.repo_dir,
["--literal-pathspecs", "ls-files", "--"] + cleanup_paths,
)
if !tracked_result.success {
return __adoption_failure(
"discovering tracked refresh output failed: "
+ trim((tracked_result.stderr ?? "") + (tracked_result.stdout ?? "")),
)
}
const tracked = __lines(tracked_result.stdout)
if len(tracked) > 0 {
const restore = __git(
tools,
cfg.repo_dir,
["--literal-pathspecs", "restore", "--source=HEAD", "--staged", "--worktree", "--"]
+ tracked,
)
if !restore.success {
return __adoption_failure(
"restoring discarded refresh output failed: "
+ trim((restore.stderr ?? "") + (restore.stdout ?? "")),
)
}
}
}
const remaining = __git(tools, cfg.repo_dir, ["status", "--porcelain", "--untracked-files=no"])
if !remaining.success || trim(remaining.stdout ?? "") != "" {
return __adoption_failure(
"tracked checkout cleanup was incomplete: "
+ trim((remaining.stderr ?? "") + (remaining.stdout ?? "")),
)
}
const checkout = __git(tools, cfg.repo_dir, ["checkout", "--detach", to_oid])
if !checkout.success {
return __adoption_failure(
"checking out ${to_oid} failed: " + trim((checkout.stderr ?? "") + (checkout.stdout ?? "")),
)
}
const head = lowercase(trim(__git(tools, cfg.repo_dir, ["rev-parse", "HEAD"]).stdout ?? ""))
if head != to_oid {
return __adoption_failure("checkout of ${to_oid} left HEAD at ${head}")
}
return {
ok: true,
adopted_oid: head,
conflicts: [],
detail: "adopted ${to_oid} for adoption ${request.attempt}",
}
}
fn __release_finalized(release: LiveBumpRelease) -> bool {
const missing = REQUIRED_RELEASE_ASSETS.filter({ name -> !contains(release.asset_names, name) })
.to_list()
return release.found && !release.draft && !release.prerelease && len(missing) == 0
}
/**
* Build the local adapter around one package-owned remote capability.
*
* @effects: [fs, process, net]
* @errors: [invalid target tags or delegated local/provider failures]
*/
pub fn live_bump_adapter(
clock: HarnessClock,
tools: HarnessTools,
remote: LiveBumpRemote,
config: dict = {},
fs: HarnessFs? = nil,
) -> BumpAdapter {
const cfg = live_bump_config(config)
return {
resolve_target_tag: fn(tag) {
const requested = trim(tag ?? "")
const resolved = if requested == "" {
trim(remote.latest_release_tag())
} else {
requested
}
if !is_v_semver(resolved) {
throw "std/bump/live: expected a tag like vX.Y.Z, got '${resolved}'"
}
return resolved
},
release_ready: fn(target) {
let attempts = 0
let lines = []
const found = poll_until(
clock,
{ ->
attempts = attempts + 1
const view = remote.release_view(target)
const ready = __release_finalized(view)
lines = lines + ["attempt ${attempts}: ${target} finalized=${ready}"]
return if ready {
view
} else {
nil
}
},
{
max_attempts: cfg.readiness_max_attempts,
interval_ms: cfg.readiness_interval_ms,
timeout_ms: cfg.readiness_timeout_ms,
},
)
const ready = found != nil
return {
ready: ready,
detail: if ready {
"${target} finalized with required assets present"
} else {
"${target} is not fully published yet in ${cfg.release_repo}"
},
attempts: attempts,
attempt_lines: lines,
}
},
read_current_version: fn() { return __read_version(fs, cfg.version_file) },
base_sha: fn() { return trim(__git(tools, cfg.repo_dir, ["rev-parse", "HEAD"]).stdout ?? "") },
apply_version: fn(target) { return __apply_version(fs, tools, cfg, target) },
run_validation: fn() {
if trim(cfg.validation_command) == "" {
return {ok: true, steps: [], detail: "no validation command declared"}
}
const result = __shell(tools, cfg.repo_dir, cfg.validation_command)
const ok = result.success ?? false
return {
ok: ok,
steps: [{name: "validation", ok: ok, detail: cfg.validation_command}],
detail: if ok {
"validation passed"
} else {
trim((result.stderr ?? "") + (result.stdout ?? ""))
},
}
},
working_changes: fn() { return __working_changes(tools, cfg.repo_dir) },
remote_base_head: remote.base_head,
adopt_base: fn(request) { return __adopt_base(fs, tools, remote, cfg, request) },
find_bump_pr: remote.find_bump_pr,
disable_auto_merge: remote.disable_auto_merge,
publish_commit: remote.publish_commit,
branch_matches_base: remote.branch_matches_base,
upsert_pr: remote.upsert_pr,
close_pr: remote.close_pr,
enable_auto_merge: remote.enable_auto_merge,
}
}