harn-stdlib 0.10.18

Embedded Harn standard library source catalog
Documentation
// std/worktree — isolated git worktree helpers built on std/runtime process exec.
//
// Every git subprocess these helpers spawn runs non-interactive by
// default: `GIT_TERMINAL_PROMPT=0` plus an empty askpass and an ssh
// `BatchMode=yes` transport. Harn runs worktree git from TTY-less
// contexts (`harn serve`, `@job`, CI); without this guard a clone/fetch
// that needs a credential or host-key decision would block on an
// interactive prompt and hang the runtime. The guard only disables
// *prompting* — credentials supplied via env/helper/agent still work —
// and is merged (`env_mode: "merge"`) so inherited PATH/HOME survive.
// Callers that genuinely want interactive git can override by passing
// their own `env` / `env_mode` in `options`.
//
// Import: import "std/worktree"
import { unique } from "std/collections"
import { git_env_remove, git_noninteractive_env } from "std/git"
import { process_run, process_shell } from "std/runtime"

const WORKTREE_PRUNE_DEFAULT_MIN_AGE_SECONDS = 60 * 60

const WORKTREE_PRUNE_DEFAULT_DISPOSABLE_PATHS = [
  "target",
  ".target",
  ".build",
  ".harn",
  "node_modules",
  "Pods",
  ".venv",
  "venv",
  "__pycache__",
  ".codex",
  ".burin",
  ".DS_Store",
]

pub type WorktreePruneReason = "main_checkout" | "current_worktree" | "in_use" | "dirty" | "unpushed" | "not_merged" | "too_young"

pub type WorktreePruneFacts = {
  path: string,
  branch?: string,
  is_main: bool,
  is_current: bool,
  has_process: bool,
  dirty: bool,
  unpushed: bool,
  merged: bool,
  abandoned: bool,
  age_seconds: int,
  size_bytes?: int,
}

pub type WorktreePruneVerdict = {reclaimable: bool, reason?: WorktreePruneReason, label?: string}

pub type WorktreeStatusEditOptions = {disposable_paths?: list<string>}

/**
 * worktree_prune_default_min_age_seconds returns the default age floor for
 * guarded worktree pruning. Younger worktrees are treated as active even when
 * they otherwise look clean.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_prune_default_min_age_seconds() -> int {
  return WORKTREE_PRUNE_DEFAULT_MIN_AGE_SECONDS
}

/**
 * worktree_prune_default_disposable_paths returns path prefixes that are
 * treated as disposable build/cache output when interpreting git status.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_prune_default_disposable_paths() -> list<string> {
  return WORKTREE_PRUNE_DEFAULT_DISPOSABLE_PATHS
}

/**
 * worktree_prune_reason_label returns stable user-facing text for a
 * non-reclaimable worktree guard.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_prune_reason_label(reason: WorktreePruneReason) -> string {
  if reason == "main_checkout" {
    return "main checkout"
  }
  if reason == "current_worktree" {
    return "current worktree"
  }
  if reason == "in_use" {
    return "in use (process attached)"
  }
  if reason == "dirty" {
    return "dirty (uncommitted edits)"
  }
  if reason == "unpushed" {
    return "unpushed commits"
  }
  if reason == "not_merged" {
    return "branch not merged"
  }
  return "too recent"
}

fn __worktree_prune_protected(reason: WorktreePruneReason) -> WorktreePruneVerdict {
  return {reclaimable: false, reason: reason, label: worktree_prune_reason_label(reason)}
}

/**
 * worktree_prune_classify is the pure safety policy for deciding whether a git
 * worktree may be reclaimed. It never shells out; callers supply already-gathered
 * facts so the policy is deterministic and easy to test.
 *
 * A worktree is reclaimable only when it is linked, not current, unused, clean,
 * has no unpushed work, is merged or abandoned, and is older than the age floor.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_prune_classify(
  entry: WorktreePruneFacts,
  min_age_seconds: int = WORKTREE_PRUNE_DEFAULT_MIN_AGE_SECONDS,
) -> WorktreePruneVerdict {
  if entry.is_main {
    return __worktree_prune_protected("main_checkout")
  }
  if entry.is_current {
    return __worktree_prune_protected("current_worktree")
  }
  if entry.has_process {
    return __worktree_prune_protected("in_use")
  }
  if entry.dirty {
    return __worktree_prune_protected("dirty")
  }
  if entry.unpushed {
    return __worktree_prune_protected("unpushed")
  }
  if !entry.merged && !entry.abandoned {
    return __worktree_prune_protected("not_merged")
  }
  if entry.age_seconds < min_age_seconds {
    return __worktree_prune_protected("too_young")
  }
  return {reclaimable: true}
}

/**
 * worktree_prune_normalize_disposable_path returns a safe repo-relative path
 * prefix for disposable artifact cleanup, or nil for unsafe inputs. Absolute
 * paths, dot-dot traversal, empty paths, and `.git` paths are rejected.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_prune_normalize_disposable_path(raw: string) -> string? {
  let normalized = replace(trim(raw ?? ""), "\\", "/")
  if starts_with(normalized, "/") {
    return nil
  }
  normalized = regex_replace("^\\./+", "", normalized)
  normalized = regex_replace("/+$", "", normalized)
  if normalized == "" || normalized == ".git" || starts_with(normalized, ".git/") {
    return nil
  }
  for component in split(normalized, "/") {
    if component == "" || component == "." || component == ".." {
      return nil
    }
  }
  return normalized
}

fn __worktree_prune_disposable_paths(extra_paths: list<string> = []) -> list<string> {
  let out = []
  for path in WORKTREE_PRUNE_DEFAULT_DISPOSABLE_PATHS + (extra_paths ?? []) {
    const normalized = worktree_prune_normalize_disposable_path(path)
    if normalized != nil {
      out = out + [normalized]
    }
  }
  return unique(out)
}

fn __worktree_prune_status_path(line: string) -> string {
  if len(line) < 3 {
    return ""
  }
  const raw = trim(substring(line, 3))
  const renamed = split(raw, " -> ")
  return renamed[len(renamed) - 1] ?? raw
}

fn __worktree_prune_path_matches_prefix(path: string, prefix: string) -> bool {
  const normalized = replace(trim(path), "\\", "/")
  const clean = regex_replace("^\\./+", "", normalized)
  return clean == prefix || starts_with(clean, prefix + "/")
}

/**
 * worktree_prune_status_has_real_edits returns true when a `git status
 * --porcelain` stream contains source/user edits after ignoring known
 * disposable build/cache paths. It is pure so product hosts can share the same
 * cleanup policy without depending on wall-clock or git process behavior.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_prune_status_has_real_edits(
  porcelain: string,
  options: WorktreeStatusEditOptions = {},
) -> bool {
  const disposable_paths = __worktree_prune_disposable_paths(options.disposable_paths ?? [])
  for line in split(porcelain ?? "", "\n") {
    if len(line) < 3 {
      continue
    }
    const path = __worktree_prune_status_path(line)
    if path == "" {
      continue
    }
    let disposable = false
    for prefix in disposable_paths {
      if __worktree_prune_path_matches_prefix(path, prefix) {
        disposable = true
        break
      }
    }
    if !disposable {
      return true
    }
  }
  return false
}

/**
 * worktree_noninteractive_env returns the default prompt guard env applied
 * to git subprocesses so credential/host-key prompts fail fast. Alias of
 * `git_noninteractive_env` — the single source of truth for the guard.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_noninteractive_env() -> dict {
  return git_noninteractive_env()
}

/**
 * __worktree_git_options builds process.exec options for a git command in
 * `cwd`: non-interactive guard env merged with the inherited environment,
 * the std/git ambient-env strip, and any caller overrides from `options`.
 */
fn __worktree_git_options(cwd, options) -> dict {
  const opts = options ?? {}
  // Caller `env` overrides individual guard keys; `env_mode` defaults to
  // "merge" so inherited PATH/HOME/credentials survive.
  const env = git_noninteractive_env() + (opts?.env ?? {})
  let out = {
    cwd: cwd,
    env: env,
    env_mode: opts?.env_mode ?? "merge",
    env_remove: opts?.env_remove ?? git_env_remove(),
  }
  for key in ["timeout_ms", "stdin", "capture", "max_inline_bytes", "policy_context"] {
    if opts[key] != nil {
      out = out + {[key]: opts[key]}
    }
  }
  return out
}

/**
 * __worktree_git runs one argv-mode git command in `cwd` with the
 * non-interactive guard applied. `args` omits the leading "git".
 */
fn __worktree_git(cwd, args, options) {
  return process_run(["git"] + args, __worktree_git_options(cwd, options))
}

/**
 * worktree_default_path.
 *
 * @effects: []
 * @errors: []
 */
pub fn worktree_default_path(repo, name) {
  return repo + "/.harn/worktrees/" + name
}

/**
 * worktree_create.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn worktree_create(repo, name, base_ref, path, options = {}) {
  const target = if path == nil || path == "" {
    worktree_default_path(repo, name)
  } else {
    path
  }
  harness.fs.mkdir(repo + "/.harn")
  harness.fs.mkdir(repo + "/.harn/worktrees")
  const result = __worktree_git(repo, ["worktree", "add", "-B", name, target, base_ref], options)
  return {repo: repo, name: name, path: target, base_ref: base_ref, result: result, success: result?.success}
}

/**
 * worktree_remove.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn worktree_remove(repo, path, force, options = {}) {
  if force {
    return __worktree_git(repo, ["worktree", "remove", "--force", path], options)
  }
  return __worktree_git(repo, ["worktree", "remove", path], options)
}

/**
 * worktree_status.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn worktree_status(path, options = {}) {
  return __worktree_git(path, ["status", "--short", "--branch"], options)
}

/**
 * worktree_diff.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn worktree_diff(path, base_ref, options = {}) {
  if base_ref == nil || base_ref == "" {
    return __worktree_git(path, ["diff", "--stat"], options)
  }
  return __worktree_git(path, ["diff", base_ref + "...HEAD"], options)
}

/**
 * worktree_shell.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn worktree_shell(path, script, options = {}) {
  return process_shell(script, __worktree_git_options(path, options))
}