← Files NightshiftARCHIVED FILE

lib/paths.sh

13.8 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

#!/usr/bin/env bash
# Workspace resolution and canonical path helpers shared by Nightshift hooks.

# ns_msys_path <path>
# Git Bash cannot cd to D:/foo; it wants /d/foo. Do not call cygpath -u here:
# it remaps Windows Temp to /tmp, which is a different directory, so helpers
# refuse a project PowerShell just created.
ns_msys_path() {
  local p d
  p="${1//\\//}"
  case "$p" in
    [A-Za-z]:/*)
      d=$(printf '%s' "${p%"${p#?}"}" | tr '[:upper:]' '[:lower:]')
      printf '%s' "/${d}${p#?:}"
      ;;
    *)
      printf '%s' "$p"
      ;;
  esac
}

# ns_native_display_path <path>
# Receipts print the native Windows form so the bash and PowerShell renderers
# stay byte-identical when Git Bash is the twin. POSIX paths are unchanged.
ns_native_display_path() {
  local p d rest converted
  p="$1"
  [ -n "$p" ] || return 0
  if command -v cygpath >/dev/null 2>&1; then
    converted="$(cygpath -w -- "$p" 2>/dev/null)" || converted=""
    if [ -n "$converted" ]; then
      printf '%s' "$converted"
      return 0
    fi
  fi
  case "$(uname -s 2>/dev/null)" in
    MINGW* | MSYS* | CYGWIN*) ;;
    *)
      printf '%s' "$p"
      return 0
      ;;
  esac
  p="${p//\\//}"
  case "$p" in
    /[A-Za-z]/*)
      d="${p#/}"
      d="${d%"${d#?}"}"
      d=$(printf '%s' "$d" | tr '[:lower:]' '[:upper:]')
      rest="${p#/?}"
      printf '%s' "${d}:${rest//\//\\}"
      ;;
    [A-Za-z]:/*)
      d=$(printf '%s' "${p%"${p#?}"}" | tr '[:lower:]' '[:upper:]')
      rest="${p#?:}"
      printf '%s' "${d}:${rest//\//\\}"
      ;;
    *)
      printf '%s' "${p//\//\\}"
      ;;
  esac
}

# ns_state_dir_owner <dir>
#
# The folder a working directory stands for. A directory inside a `.nightshift/` state folder is
# that folder's owner, the directory above it: a shell left in `.nightshift/` or `.nightshift/run/`
# still means the workspace, never a nested `.nightshift/.nightshift`. A `.nightshift` folder that
# holds its own `.nightshift/` is a workspace in its own right and is kept. Anything else, and a
# path that does not resolve, is printed as given.
ns_state_dir_owner() {
  local dir probe
  dir="$(cd -P "$1" 2>/dev/null && pwd)" || {
    printf '%s' "$1"
    return 0
  }
  probe="$dir"
  while [ -n "$probe" ] && [ "$probe" != / ]; do
    if [ "${probe##*/}" = .nightshift ] && [ ! -d "$probe/.nightshift" ]; then
      probe="${probe%/*}"
      printf '%s' "${probe:-/}"
      return 0
    fi
    probe="${probe%/*}"
  done
  printf '%s' "$dir"
}

# ns_workspace_root <host-root>
#
# Resolve the one workspace that owns Nightshift state. Normally that is the task root itself.
# A task opened elsewhere may opt in explicitly with a local .nightshift-link containing one
# absolute path to a directory that already owns .nightshift/. We never search parent or sibling
# directories: an absent link means local state; a malformed link returns 2 so callers can fail
# closed instead of silently running without the owner's contract.
ns_workspace_root() {
  local host link target="" lines="" canonical=""
  host="$(ns_msys_path "$1")"
  link="$host/.nightshift-link"
  canonical="$(cd -P "$host" 2>/dev/null && pwd)" || {
    return 2
  }
  if [ ! -e "$link" ] && [ ! -L "$link" ]; then
    printf '%s' "$canonical"
    return 0
  fi
  if [ ! -f "$link" ] || [ -L "$link" ]; then
    return 2
  fi
  IFS= read -r target <"$link" || true
  lines="$(awk 'END { print NR + 0 }' "$link" 2>/dev/null)"
  if [ -z "$target" ] || [ "$lines" -ne 1 ]; then
    return 2
  fi
  case "$target" in /*) ;; *)
    return 2
  esac
  canonical="$(cd -P "$target" 2>/dev/null && pwd)" || {
    return 2
  }
  [ -d "$canonical/.nightshift" ] || {
    return 2
  }
  printf '%s' "$canonical"
}

# ns_hook_host_dir — project dir from the host environment, never from stdin.
ns_hook_host_dir() {
  printf '%s' "${CURSOR_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-$PWD}}}"
}

# ns_hook_idle_exit — after the library is loaded, leave if the resolved site
# has no armed shift. A broken .nightshift-link is not idle: the caller fail-
# closes. Revival workers stay in.
ns_hook_idle_exit() {
  [ "${NIGHTSHIFT_REVIVAL:-}" != "1" ] || return 0
  local host project ns armed ended
  host="$(ns_hook_host_dir)"
  project="$(ns_workspace_root "$host" 2>/dev/null)" || return 0
  ns="$project/.nightshift"
  ns_layout_set armed "$ns" armed
  [ -f "$armed" ] || exit 0
  ns_layout_set ended "$ns" ended
  if [ -f "$ended" ] && [ ! -L "$ended" ]; then
    exit 0
  fi
}

# ns_record_workspace_link <host-root> <workspace>
# Validate and atomically record a cross-workspace link. The pointer is machine-local, so when
# the host is a Git repository it goes in .git/info/exclude rather than changing tracked files.
ns_record_workspace_link() {
  local host target canonical tmp exclude git_dir
  host="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
  target="$2"
  case "$target" in /*) ;; *) return 1 ;; esac
  canonical="$(cd -P "$target" 2>/dev/null && pwd)" || return 1
  [ -d "$canonical/.nightshift" ] || return 1
  if ns_is_scratch_path "$host" || ns_is_scratch_path "$canonical"; then
    return 1
  fi
  tmp="$host/.nightshift-link.$$"
  printf '%s\n' "$canonical" >"$tmp" || return 1
  mv "$tmp" "$host/.nightshift-link" || return 1
  if git_dir="$(git -C "$host" rev-parse --git-dir 2>/dev/null)"; then
    case "$git_dir" in /*) ;; *) git_dir="$host/$git_dir" ;; esac
    exclude="$git_dir/info/exclude"
    mkdir -p "${exclude%/*}" || return 1
    grep -qxF '.nightshift-link' "$exclude" 2>/dev/null || printf '%s\n' '.nightshift-link' >>"$exclude"
  fi
}

# Point the work-target at this workspace so a revival started there finds .nightshift/.
# No-op when the target is the workspace itself or already linked here.
ns_ensure_work_target_link() { # <workspace>
  local ws target existing
  ws="$(cd -P "$1" 2>/dev/null && pwd)" || return 1
  target="$(ns_work_target "$ws" 2>/dev/null)" || return 0
  [ -n "$target" ] || return 0
  [ "$target" = "$ws" ] && return 0
  [ -d "$target" ] || return 1
  if existing="$(ns_workspace_root "$target" 2>/dev/null)" && [ "$existing" = "$ws" ]; then
    return 0
  fi
  ns_record_workspace_link "$target" "$ws"
}

# ns_path_under_protected <path> <protectedDirs>
ns_path_under_protected() {
  local path="${1#./}" d
  path="${path#./}"
  IFS=' |' read -ra _ns_pd_dirs <<<"$2"
  for d in "${_ns_pd_dirs[@]}"; do
    [ -n "$d" ] || continue
    d="${d#./}"
    case "$path" in
      "$d" | "$d"/* | */"$d" | */"$d"/*) return 0 ;;
    esac
  done
  return 1
}

# ns_under_nightshift <workspace> <relative-path>
# Print the canonical path when it resolves to a valid child of .nightshift/.
# Rejects symlinks, traversal, and anything that escapes the root.
ns_under_nightshift() {
  local ws="$1" rel="$2" ns root parent base canon
  case "$rel" in
    '' | /* | *..*) return 1 ;;
  esac
  ns="$ws/.nightshift"
  root="$(cd -P "$ns" 2>/dev/null && pwd)" || return 1
  [ ! -L "$ns/$rel" ] || return 1
  if [ -d "$ns/$rel" ]; then
    canon="$(cd -P "$ns/$rel" 2>/dev/null && pwd)" || return 1
  elif [ -f "$ns/$rel" ]; then
    base="${rel##*/}"
    if [ "$rel" = "$base" ]; then
      parent="$root"
    else
      parent="$(cd -P "$ns/${rel%/*}" 2>/dev/null && pwd)" || return 1
    fi
    [ -f "$parent/$base" ] || return 1
    [ ! -L "$parent/$base" ] || return 1
    canon="$parent/$base"
  else
    return 1
  fi
  case "$canon" in
    "$root"/*) ;;
    *) return 1 ;;
  esac
  printf '%s' "$canon"
}

# Qualify bare .nightshift/ mentions in owner-authored injection text (clock-out,
# revival, toolDeny) so a drifted cwd cannot send the agent to a nested copy, and name each
# state file where this workspace's layout keeps it: text written for one layout still sends the
# agent to the right file in another. Expansion happens at injection time; the owner's rules file
# keeps the relative form so it stays editable without a skill variable.
ns_expand_injected_paths() {
  local ws="$1" text="$2" version
  [ -n "$ws" ] || { printf '%s' "$text"; return 0; }
  text="${text//\$NIGHTSHIFT_WORKSPACE/$ws}"
  text="${text//\$NS/$ws/.nightshift}"
  ns_layout_version_set version "$ws/.nightshift"
  printf '%s' "$text" | NS_LAYOUT_ROWS="$NS_LAYOUT_ROWS" awk -v ws="$ws" -v version="$version" '
    BEGIN {
      ORS = ""
      n = split(ENVIRON["NS_LAYOUT_ROWS"], rows, "\n")
      for (k = 1; k <= n; k++) {
        if (split(rows[k], f, "\t") < 4 || f[4] == "field" || f[4] == "retired" || f[4] == "stray" || index(f[3], "*")) continue
        key[k] = f[1]
        path[k] = f[3]
        if (f[2] + 0 <= version && (!(f[1] in best) || f[2] + 0 >= bestv[f[1]])) {
          best[f[1]] = f[3]
          bestv[f[1]] = f[2] + 0
        }
      }
      m = 0
      for (k = 1; k <= n; k++) {
        if (!(k in key) || !(key[k] in best) || path[k] == best[key[k]]) continue
        from[++m] = path[k]
        to[m] = best[key[k]]
      }
      # The longest name first, so a file is never read as the folder it sits in.
      for (a = 2; a <= m; a++) {
        for (b = a; b > 1 && length(from[b]) > length(from[b - 1]); b--) {
          t = from[b]; from[b] = from[b - 1]; from[b - 1] = t
          t = to[b]; to[b] = to[b - 1]; to[b - 1] = t
        }
      }
    }
    {
      s = $0
      while (match(s, /\.nightshift[\/\\]/)) {
        prefix = substr(s, 1, RSTART - 1)
        chunk = substr(s, RSTART, RLENGTH)
        sep = substr(chunk, length(chunk), 1)
        last = (length(prefix) ? substr(prefix, length(prefix), 1) : "")
        if (last == "/" || last == "\\") {
          printf "%s%s", prefix, chunk
        } else {
          printf "%s%s/.nightshift%s", prefix, ws, sep
        }
        s = substr(s, RSTART + RLENGTH)
        for (j = 1; j <= m; j++) {
          if (substr(s, 1, length(from[j])) == from[j] && substr(s, length(from[j]) + 1, 1) !~ /[A-Za-z0-9._-]/) {
            printf "%s", to[j]
            s = substr(s, length(from[j]) + 1)
            break
          }
        }
      }
      printf "%s", s
    }
  '
}

# ns_is_scratch_path <absolute-path>
# ChatGPT disposable workspaces live under /workspace/scratch/. Local non-git folders are not
# scratch merely because they lack Git.
ns_is_scratch_path() {
  local p="${1%/}"
  p="${p//\\//}"
  case "$p" in
    /workspace/scratch | /workspace/scratch/*) return 0 ;;
  esac
  return 1
}

# ns_work_mode <workspace>
# Print repository or artifact. A missing record is repository — the historical default.
# Return 1 when the record exists and is not one of those two words.
ns_work_mode() {
  local record mode=""
  ns_layout_set record "$1/.nightshift" work-mode
  if [ -L "$record" ]; then
    return 1
  fi
  if [ ! -s "$record" ]; then
    printf 'repository'
    return 0
  fi
  IFS= read -r mode <"$record" || true
  mode="${mode%$'\r'}"
  case "$mode" in
    repository | artifact)
      printf '%s' "$mode"
      return 0
      ;;
  esac
  return 1
}

# ns_record_work_mode <workspace> <repository|artifact>
ns_record_work_mode() {
  local project="$1" mode="$2" record tmp
  case "$mode" in repository | artifact) ;; *) return 1 ;; esac
  ns_state_dir_ensure "$project" || return 1
  ns_layout_parent "$project/.nightshift" work-mode || return 1
  ns_layout_set record "$project/.nightshift" work-mode
  tmp="${record%/*}/.work-mode.$$"
  printf '%s\n' "$mode" >"$tmp" || return 1
  mv "$tmp" "$record"
}

# ns_propose_work_mode <workspace>
# Detect the mode Setup should offer. Does not persist. 0 prints repository or artifact;
# 2 is a disposable scratch path; 1 is undecidable.
ns_propose_work_mode() {
  local project="$1" child base top found=""
  if ns_is_scratch_path "$project"; then
    return 2
  fi
  project="$(cd -P "$project" 2>/dev/null && pwd)" || return 1
  if ns_is_scratch_path "$project"; then
    return 2
  fi
  if git -C "$project" rev-parse --show-toplevel >/dev/null 2>&1; then
    printf 'repository'
    return 0
  fi
  for child in "$project"/*/; do
    base="${child%/}"; base="${base##*/}"
    case "$base" in .*) continue ;; esac
    [ -L "${child%/}" ] && continue
    top="$(git -C "$child" rev-parse --show-toplevel 2>/dev/null)" || continue
    if [ -n "$found" ] && [ "$found" != "$top" ]; then
      printf 'repository'
      return 0
    fi
    found="$top"
  done
  if [ -n "$found" ]; then
    printf 'repository'
    return 0
  fi
  printf 'artifact'
  return 0
}

# ns_normalize_path <path> — the path with its `.` and `..` segments resolved as text, the way a
# relative link is read. A `..` above the start is kept.
ns_normalize_path() {
  local in="$1" out="" seg rest lead=""
  case "$in" in /*) lead=/ ;; esac
  rest="$in"
  while [ -n "$rest" ]; do
    seg="${rest%%/*}"
    case "$rest" in */*) rest="${rest#*/}" ;; *) rest="" ;; esac
    case "$seg" in
      '' | .) ;;
      ..)
        case "$out" in
          '' | .. | */..) out="${out:+$out/}.." ;;
          */*) out="${out%/*}" ;;
          *) out="" ;;
        esac
        ;;
      *) out="${out:+$out/}$seg" ;;
    esac
  done
  printf '%s%s' "$lead" "$out"
}

# ns_relative_path <from-dir> <to-path> — <to-path> relative to <from-dir>. Both are absolute and
# spelled alike up to where they part, as two paths built from one state directory are.
ns_relative_path() {
  local common="${1%/}" to="$2" up=""
  while [ -n "$common" ]; do
    case "$to" in "$common"/*) break ;; esac
    common="${common%/*}"
    up="../$up"
  done
  printf '%s%s' "$up" "${to#"$common"/}"
}

# ns_state_dir_ensure <workspace> — create <workspace>/.nightshift/ when it does not exist yet. A
# state directory is born in the current layout, so a new one gets its state-version before any
# file lands in it; an existing one is left exactly as it is.
ns_state_dir_ensure() {
  [ -d "$1/.nightshift" ] && return 0
  mkdir -p "$1/.nightshift" || return 1
  ns_write_state_version "$1" "$NS_LAYOUT_VERSION"
}

# ns_layout_parent <state-dir> <key> — create the directory <key> lives in, for a writer that may
# be the first to use it.
ns_layout_parent() {
  local _nsl_out
  ns_layout_set _nsl_out "$1" "$2" || return 1
  mkdir -p "${_nsl_out%/*}" 2>/dev/null
}

SHA-256: aeaa16399ff64716670e3f90ffc3955b0bdec39f4291292263699419fa1e93f5