diff --git a/Cargo.lock b/Cargo.lock index 42f3b775..f46afc3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,7 +720,7 @@ dependencies = [ "ipc-channel", "itertools 0.14.0", "libc", - "linux-perf-data 0.12.0", + "linux-perf-data 0.12.0 (git+https://github.com/mstange/linux-perf-data.git?rev=da5bce4b9fb724e84b1eea0cb6ab9c8a291bc676)", "log", "md5", "memmap2", @@ -1402,7 +1402,7 @@ dependencies = [ [[package]] name = "fxprof-processed-profile" version = "0.8.1" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "bitflags 2.11.1", "debugid", @@ -2310,6 +2310,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" +[[package]] +name = "linux-perf-data" +version = "0.12.0" +source = "git+https://github.com/CodSpeedHQ/linux-perf-data?rev=87308af9cad1#87308af9cad13fc93d7e7100652cc05e531c37a4" +dependencies = [ + "byteorder", + "linear-map", + "linux-perf-event-reader 0.10.2 (git+https://github.com/AvalancheHQ/linux-perf-event-reader?rev=908775c8b5bd)", + "memchr", + "prost", + "prost-derive", + "thiserror 2.0.18", +] + [[package]] name = "linux-perf-data" version = "0.12.0" @@ -2317,7 +2331,7 @@ source = "git+https://github.com/mstange/linux-perf-data.git?rev=da5bce4b9fb724e dependencies = [ "byteorder", "linear-map", - "linux-perf-event-reader", + "linux-perf-event-reader 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", "memchr", "prost", "prost-derive", @@ -2333,12 +2347,11 @@ checksum = "79544deaf2626fe2d10e5f87af7b7fca93c0d0062fc6ec84fc24e463039c6750" dependencies = [ "byteorder", "linear-map", - "linux-perf-event-reader", + "linux-perf-event-reader 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", "memchr", "prost", "prost-derive", "thiserror 2.0.18", - "zstd-safe", ] [[package]] @@ -2353,6 +2366,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "linux-perf-event-reader" +version = "0.10.2" +source = "git+https://github.com/AvalancheHQ/linux-perf-event-reader?rev=908775c8b5bd#908775c8b5bd620ea4ec767d7e56afb3b2f232ac" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4077,7 +4101,7 @@ dependencies = [ [[package]] name = "samply" version = "0.13.1" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "bitflags 2.11.1", "byteorder", @@ -4099,7 +4123,7 @@ dependencies = [ "indexmap", "lazy_static", "libc", - "linux-perf-data 0.13.0", + "linux-perf-data 0.12.0 (git+https://github.com/CodSpeedHQ/linux-perf-data?rev=87308af9cad1)", "log", "mach2", "memchr", @@ -4146,7 +4170,7 @@ dependencies = [ [[package]] name = "samply-api" version = "0.24.0" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "samply-debugid", "samply-symbols", @@ -4162,7 +4186,7 @@ dependencies = [ [[package]] name = "samply-debugid" version = "0.1.0" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "debugid", "uuid", @@ -4171,7 +4195,7 @@ dependencies = [ [[package]] name = "samply-object" version = "0.1.0" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "debugid", "object", @@ -4182,7 +4206,7 @@ dependencies = [ [[package]] name = "samply-quota-manager" version = "0.1.0" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "bytesize", "futures", @@ -4196,7 +4220,7 @@ dependencies = [ [[package]] name = "samply-symbols" version = "0.24.1" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "addr2line", "bitflags 2.11.1", @@ -5627,7 +5651,7 @@ dependencies = [ [[package]] name = "wholesym" version = "0.8.1" -source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=ec97a70c0667098f8607f30a607ddd031a15a8b8#ec97a70c0667098f8607f30a607ddd031a15a8b8" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?rev=81ba2c346e71#81ba2c346e71d05aaef94448e2962961d29cb4c8" dependencies = [ "bytes", "core-foundation 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index 482f1f62..369305eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ rmp-serde = "1.3.1" uuid = { version = "1.23.1", features = ["v4"] } which = "8.0.2" crc32fast = "1.5.0" -samply = { git = "https://github.com/CodSpeedHQ/samply-codspeed", rev = "ec97a70c0667098f8607f30a607ddd031a15a8b8" } +samply = { git = "https://github.com/CodSpeedHQ/samply-codspeed", rev = "81ba2c346e71" } [target.'cfg(target_os = "linux")'.dependencies] procfs = "0.18" diff --git a/crates/runner-shared/src/perf_event.rs b/crates/runner-shared/src/perf_event.rs index 1dfe2af4..1c7e2091 100644 --- a/crates/runner-shared/src/perf_event.rs +++ b/crates/runner-shared/src/perf_event.rs @@ -1,15 +1,55 @@ /// Subset of perf events that CodSpeed supports. +/// +/// Each variant is a semantic slot of the cache/execution model; the concrete +/// perf event chosen for it depends on the architecture (see +/// [`Self::to_perf_string`]). #[derive(Debug, Clone, Copy)] pub enum PerfEvent { CpuCycles, + /// L1 data cache accesses. L1DCache, + /// Accesses one level below L1: what L1 misses spill into. Hits in L1 are + /// derived as `L1DCache - L2DCache`. L2DCache, + /// Misses out of the last profiled cache level (i.e. trips to memory). + /// Hits below L1 are derived as `L2DCache - CacheMisses`. CacheMisses, Instructions, } impl PerfEvent { + /// Every perf event name that can back this slot, across all supported + /// architectures. For parsers, which must handle profiles recorded on any + /// architecture regardless of where they run. + pub fn perf_strings(&self) -> &'static [&'static str] { + match self { + PerfEvent::CpuCycles => &["cpu-cycles"], + PerfEvent::L1DCache => &["l1d_cache", "L1-dcache-loads"], + PerfEvent::L2DCache => &["l2d_cache", "L1-dcache-load-misses"], + PerfEvent::CacheMisses => &["l2d_cache_refill", "cache-misses"], + PerfEvent::Instructions => &["instructions"], + } + } + + /// The perf event name backing this slot on the current architecture. + /// + /// On arm64 these are the architected PMU events (resolved through sysfs): + /// `l2d_cache` counts all L2 accesses and `l2d_cache_refill` its misses. + /// On x86_64 there is no generalized combined L2 event, so the slots are + /// backed by the generalized cache events: L1 read misses stand in for + /// "accesses below L1", and `cache-misses` (last-level misses) for trips + /// to memory — lumping L2 and L3 hits together in the derived + /// `L2DCache - CacheMisses`. pub fn to_perf_string(&self) -> &'static str { + #[cfg(target_arch = "x86_64")] + match self { + PerfEvent::CpuCycles => "cpu-cycles", + PerfEvent::L1DCache => "L1-dcache-loads", + PerfEvent::L2DCache => "L1-dcache-load-misses", + PerfEvent::CacheMisses => "cache-misses", + PerfEvent::Instructions => "instructions", + } + #[cfg(not(target_arch = "x86_64"))] match self { PerfEvent::CpuCycles => "cpu-cycles", PerfEvent::L1DCache => "l1d_cache", @@ -28,6 +68,114 @@ impl PerfEvent { PerfEvent::Instructions, ] } + + /// Architecture-independent name for this slot in samply profiles. + /// + /// samply labels each extra-event column with the name we pass it, so + /// every architecture shares one name per slot and parsers match on it + /// directly — unlike the perf integration, where columns carry the + /// arch-specific event names of [`Self::perf_strings`]. + pub fn samply_name(&self) -> &'static str { + match self { + PerfEvent::CpuCycles => "cpu-cycles", + PerfEvent::L1DCache => "l1d-cache", + PerfEvent::L2DCache => "l2d-cache", + PerfEvent::CacheMisses => "cache-misses", + PerfEvent::Instructions => "instructions", + } + } + + /// The `::` spec for samply's `--perf-events`, + /// resolving this slot to a concrete PMU event of the CPU we are running + /// on. `None` when the slot has no suitable backing event on this CPU. + pub fn to_samply_spec(&self) -> Option { + let (event_type, config) = self.perf_event_attr()?; + Some(format!( + "{}:{}:{:#x}", + self.samply_name(), + event_type, + config + )) + } + + /// The `perf_event_attr` `(type, config)` encoding backing this slot on + /// the current CPU. + fn perf_event_attr(&self) -> Option<(u32, u64)> { + // perf_event_attr type values from . + const PERF_TYPE_HARDWARE: u32 = 0; + const PERF_TYPE_RAW: u32 = 4; + match self { + // Generalized hardware events, portable across architectures. + PerfEvent::CpuCycles => Some((PERF_TYPE_HARDWARE, 0)), + PerfEvent::Instructions => Some((PERF_TYPE_HARDWARE, 1)), + _ => Some((PERF_TYPE_RAW, self.raw_cache_config()?)), + } + } + + /// Raw PMU encoding of this cache slot on x86_64: `umask << 8 | event`. + /// + /// Only Intel has a vetted selection; other vendors get no cache events. + /// The events are picked so that each slot counts demand traffic of one + /// consistent population, keeping the derived hit counts + /// (`L1DCache - L2DCache`, `L2DCache - CacheMisses`) from underflowing + /// the way mixed-population events (e.g. loads vs. all-cause line fills) + /// can in store- or prefetch-heavy code. + #[cfg(target_arch = "x86_64")] + fn raw_cache_config(&self) -> Option { + if !is_genuine_intel() { + return None; + } + // Retired load instructions, by the cache level that served them: + // MEM_INST_RETIRED.ALL_LOADS, MEM_LOAD_RETIRED.L1_MISS and + // MEM_LOAD_RETIRED.L3_MISS. Demand loads only (stores and prefetches + // don't count), encodings stable since Skylake. + match self { + PerfEvent::L1DCache => Some(0x81d0), + PerfEvent::L2DCache => Some(0x08d1), + PerfEvent::CacheMisses => Some(0x20d1), + _ => None, + } + } + + /// Raw PMU encoding of this cache slot on arm64: the architected PMU + /// event number (Arm ARM D8.11). + #[cfg(target_arch = "aarch64")] + fn raw_cache_config(&self) -> Option { + match self { + // L1D_CACHE: L1 data cache accesses, loads and stores. + PerfEvent::L1DCache => Some(0x04), + // L1D_CACHE_REFILL: L1D line fills. Defined against the same + // access population as L1D_CACHE — unlike L2D_CACHE, which also + // counts L1 write-backs, instruction-side refills and table + // walks, and counts lines where L1D_CACHE counts operations — + // so the `L1DCache - L2DCache` hit derivation stays sound. + PerfEvent::L2DCache => Some(0x03), + // L2D_CACHE_REFILL: refills of L2 or L1 from outside those + // caches. On the Cortex-A72 macro-runner fleet (a1.metal) there + // is no L3, so these are trips to DRAM. Includes instruction-side + // refills, so it can exceed L1D_CACHE_REFILL in icache-missing + // code; the derived hit counts saturate against that. + PerfEvent::CacheMisses => Some(0x17), + _ => None, + } + } + + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + fn raw_cache_config(&self) -> Option { + None + } +} + +#[cfg(target_arch = "x86_64")] +fn is_genuine_intel() -> bool { + use std::arch::x86_64::__cpuid; + // CPUID leaf 0: vendor string in EBX,EDX,ECX. + let leaf0 = unsafe { __cpuid(0) }; + let mut vendor = [0u8; 12]; + vendor[0..4].copy_from_slice(&leaf0.ebx.to_le_bytes()); + vendor[4..8].copy_from_slice(&leaf0.edx.to_le_bytes()); + vendor[8..12].copy_from_slice(&leaf0.ecx.to_le_bytes()); + &vendor == b"GenuineIntel" } impl std::fmt::Display for PerfEvent { @@ -35,3 +183,38 @@ impl std::fmt::Display for PerfEvent { write!(f, "{}", self.to_perf_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn portable_slots_have_samply_specs() { + assert_eq!( + PerfEvent::CpuCycles.to_samply_spec().unwrap(), + "cpu-cycles:0:0x0" + ); + assert_eq!( + PerfEvent::Instructions.to_samply_spec().unwrap(), + "instructions:0:0x1" + ); + } + + #[test] + fn samply_names_are_unique() { + let mut names: Vec<_> = PerfEvent::all_events() + .iter() + .map(|event| event.samply_name()) + .collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), PerfEvent::all_events().len()); + } + + #[test] + fn print_specs_for_this_host() { + for event in PerfEvent::all_events() { + println!("{event:?} -> {:?}", event.to_samply_spec()); + } + } +} diff --git a/scripts/samply-dev.sh b/scripts/samply-dev.sh new file mode 100755 index 00000000..051007c5 --- /dev/null +++ b/scripts/samply-dev.sh @@ -0,0 +1,367 @@ +#!/bin/sh +# Toggle "samply dev mode" for the runner. +# +# Dev mode redirects the runner's `samply`, `framehop`, and +# `linux-perf-event-reader` dependencies to local sibling checkouts via +# `.cargo/config.toml` patch files, without ever touching the committed +# `Cargo.toml` / `Cargo.lock` / `.gitignore`. This lets you iterate on all +# three crates in place and have the runner pick the changes up immediately. +# +# Redirected dependencies (all resolve to siblings of the runner repo): +# - samply (../samply-codspeed) committed as a git dep in the runner's +# Cargo.toml; patched via [patch.""] +# - framehop (../framehop) committed as a git dep in samply's +# Cargo.toml; patched via [patch.""] +# - linux-perf-event-reader (../linux-perf-event-reader) +# a crates.io dep pulled in transitively via +# linux-perf-data, so it is overridden with +# [patch.crates-io] rather than a git-url patch +# +# The patch files are kept out of git locally via each repo's +# `.git/info/exclude` (a per-clone, uncommitted ignore list) — nothing is added +# to the tracked `.gitignore`. +# +# Building with the patch in place rewrites the tracked `Cargo.lock` (the +# git-rev `source` lines are dropped for path deps). `.git/info/exclude` only +# hides untracked files, so the lock is instead masked with +# `git update-index --skip-worktree`, which tells git to ignore local edits to +# a tracked file. `off` clears the flag and restores the lock to HEAD. +# +# Two patch files are managed (both locally excluded): +# - /.cargo/config.toml patches samply + framehop + reader -> local +# - /.cargo/config.toml patches framehop + reader -> local (so samply +# standalone builds also use them) +# +# The committed manifests stay pinned to git revs, so all repos remain +# committable at any time. Each repo's tracked revision is read straight from +# the committed manifests (the `rev = "..."` in the runner's and samply's +# Cargo.toml) — revisions are never hardcoded in this script. `sync` checks out +# those tracked revisions in each local checkout, skipping any checkout that has +# uncommitted changes so in-progress work is never clobbered. Bumping a rev for +# release is a separate, manual step. +# +# Every command ends by printing a recap of which local checkout each +# dependency resolves to, its current HEAD, the manifest-tracked revision, and +# whether the two agree. +# +# Usage: +# scripts/samply-dev.sh on enable dev mode (write patch files) +# scripts/samply-dev.sh off disable dev mode (remove patch files) +# scripts/samply-dev.sh status show current state +# scripts/samply-dev.sh sync check out each repo's manifest-tracked revision +set -eu + +# Resolve repo roots relative to this script, not the cwd. +RUNNER_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SAMPLY_ROOT=$(CDPATH= cd -- "$RUNNER_ROOT/../samply-codspeed" 2>/dev/null && pwd || true) +FRAMEHOP_ROOT=$(CDPATH= cd -- "$RUNNER_ROOT/../framehop" 2>/dev/null && pwd || true) +READER_ROOT=$(CDPATH= cd -- "$RUNNER_ROOT/../linux-perf-event-reader" 2>/dev/null && pwd || true) + +SAMPLY_URL="https://github.com/CodSpeedHQ/samply-codspeed" +FRAMEHOP_URL="https://github.com/CodSpeedHQ/framehop" +READER_URL="https://github.com/AvalancheHQ/linux-perf-event-reader" + +RUNNER_CONFIG="$RUNNER_ROOT/.cargo/config.toml" +SAMPLY_CONFIG="$SAMPLY_ROOT/.cargo/config.toml" + +# Manifests that pin each repo's tracked git revision (single source of truth; +# revs are never hardcoded in this script). `sync` reads `rev = "..."` from them. +# framehop -> a git dep in samply's Cargo.toml +# reader -> a [patch.crates-io] git entry in samply's Cargo.toml +# (linux-perf-event-reader comes from crates.io transitively via +# linux-perf-data, so it is overridden with [patch.crates-io], not a +# git-url patch) +# samply -> a git dep in the runner's Cargo.toml +SAMPLY_MANIFEST="$SAMPLY_ROOT/samply/Cargo.toml" +RUNNER_MANIFEST="$RUNNER_ROOT/Cargo.toml" + +# Extract the `rev = "..."` from the first manifest line mentioning $2 (a repo +# URL slug). Empty if the manifest or the entry is absent. +# manifest_rev +manifest_rev() { + manifest=$1 + needle=$2 + [ -f "$manifest" ] || return 0 + grep -E "$needle" "$manifest" 2>/dev/null \ + | grep -oE 'rev[[:space:]]*=[[:space:]]*"[0-9a-fA-F]{7,40}"' \ + | grep -oE '[0-9a-fA-F]{7,40}' \ + | head -1 \ + || true +} + +framehop_tracked_rev() { manifest_rev "$SAMPLY_MANIFEST" 'CodSpeedHQ/framehop'; } +reader_tracked_rev() { manifest_rev "$SAMPLY_MANIFEST" 'CodSpeedHQ/linux-perf-event-reader'; } +samply_tracked_rev() { manifest_rev "$RUNNER_MANIFEST" 'CodSpeedHQ/samply-codspeed'; } + +# Pattern stored in .git/info/exclude (relative to each repo root). +EXCLUDE_ENTRY="/.cargo/config.toml" + +usage() { + echo "Usage: $0 {on|off|status|sync}" >&2 + echo " on enable dev mode (write patch files)" >&2 + echo " off disable dev mode (remove patch files)" >&2 + echo " status show dev-mode state" >&2 + echo " sync check out each repo's manifest-tracked revision" >&2 + echo "(every command ends by printing the repo-wiring recap)" >&2 + exit 2 +} + +# Resolve /.git/info/exclude, failing if $1 is not a git checkout. +resolve_exclude_file() { + repo_root=$1 + git_dir=$(CDPATH= cd -- "$repo_root" && git rev-parse --git-dir 2>/dev/null) || { + echo "error: $repo_root is not a git repository" >&2 + exit 1 + } + case "$git_dir" in + /*) ;; # already absolute + *) git_dir="$repo_root/$git_dir" ;; + esac + printf '%s\n' "$git_dir/info/exclude" +} + +# Ensure $EXCLUDE_ENTRY is present in /.git/info/exclude. +add_local_exclude() { + exclude_file=$(resolve_exclude_file "$1") + mkdir -p "$(dirname -- "$exclude_file")" + if [ ! -f "$exclude_file" ] || ! grep -qxF "$EXCLUDE_ENTRY" "$exclude_file"; then + printf '%s\n' "$EXCLUDE_ENTRY" >> "$exclude_file" + fi +} + +# Remove $EXCLUDE_ENTRY from /.git/info/exclude if present. +remove_local_exclude() { + exclude_file=$(resolve_exclude_file "$1") + [ -f "$exclude_file" ] || return 0 + if grep -qxF "$EXCLUDE_ENTRY" "$exclude_file"; then + grep -vxF "$EXCLUDE_ENTRY" "$exclude_file" > "$exclude_file.tmp" && mv "$exclude_file.tmp" "$exclude_file" + fi +} + +# Restore Cargo.lock to HEAD, then mask the local build-induced edits to it. +mask_cargo_lock() { + repo_root=$1 + git -C "$repo_root" checkout -- Cargo.lock + git -C "$repo_root" update-index --skip-worktree Cargo.lock +} + +# Unmask Cargo.lock and restore it to HEAD. +unmask_cargo_lock() { + repo_root=$1 + git -C "$repo_root" update-index --no-skip-worktree Cargo.lock 2>/dev/null || true + git -C "$repo_root" checkout -- Cargo.lock 2>/dev/null || true +} + +require_dirs() { + if [ -z "$SAMPLY_ROOT" ]; then + echo "error: ../samply-codspeed not found next to the runner repo" >&2 + exit 1 + fi + if [ -z "$FRAMEHOP_ROOT" ]; then + echo "error: ../framehop not found next to the runner repo" >&2 + exit 1 + fi + if [ -z "$READER_ROOT" ]; then + echo "error: ../linux-perf-event-reader not found next to the runner repo" >&2 + exit 1 + fi +} + +enable() { + require_dirs + + add_local_exclude "$RUNNER_ROOT" + mkdir -p "$RUNNER_ROOT/.cargo" + cat > "$RUNNER_CONFIG" < "$SAMPLY_CONFIG" </dev/null || true + [ -n "$SAMPLY_ROOT" ] && rmdir "$SAMPLY_ROOT/.cargo" 2>/dev/null || true + + # Drop the local-exclude entries we added. + remove_local_exclude "$RUNNER_ROOT" + [ -n "$SAMPLY_ROOT" ] && remove_local_exclude "$SAMPLY_ROOT" + + if [ "$removed" -eq 1 ]; then + echo "samply dev mode: OFF" + else + echo "samply dev mode: already OFF" + fi +} + +# Current HEAD of a checkout (short), or "-" if missing/not a repo. +repo_head() { + repo_root=$1 + [ -n "$repo_root" ] || { printf '%s' "-"; return; } + git -C "$repo_root" rev-parse --short HEAD 2>/dev/null || printf '%s' "-" +} + +# Collapse a leading $HOME to ~ to keep paths short. +tilde() { + case "$1" in + "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; + "$HOME") printf '~' ;; + *) printf '%s' "$1" ;; + esac +} + +# Shorten a git rev to 12 chars for display (enough to identify; sync is still +# computed from the full value). "-" / empty pass through unchanged. +short_rev() { + case "$1" in + "" | "-") printf '%s' "${1:--}" ;; + *) printf '%s' "$1" | cut -c1-12 ;; + esac +} + +# Print a recap block for one dependency: a status line (glyph + label + state) +# followed by aligned path / head / tracked detail lines. +# recap_line