diff --git a/functions/_agents_repo_local_slug.fish b/functions/_agents_repo_local_slug.fish new file mode 100644 index 0000000..71f5a21 --- /dev/null +++ b/functions/_agents_repo_local_slug.fish @@ -0,0 +1,40 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_repo_local_slug +# +# DESCRIPTION +# Builds the path-derived fallback slug used when a project has no git +# remote: local--<8 hex of sha256(realpath)>. The +# basename is lowercased and every character outside [a-z0-9._-] is +# mapped to a dash, matching the sanitization the remote-URL branch of +# _agents_repo_slug applies to hostnames and paths. +# +# This is the single source of truth for that formula. It exists so the +# rule is written once: _agents_repo_slug's no-remote branch calls it to +# produce the slug, and agents-vault's slug-migration fallback (used when +# there is no live symlink yet to read the previous slug from) calls it +# to recompute the same candidate. Duplicating the formula in both places +# let them drift once before; this closes that gap for good. +# +# ARGUMENTS +# dir Absolute or relative path to the project directory +# +# EXIT STATUS +# 0 Slug printed +# 1 No directory argument given +# +# RETURNS +# The local-* slug, one line on stdout. +# +# EXAMPLE +# set -l slug (_agents_repo_local_slug /home/user/myproject) +function _agents_repo_local_slug --argument-names dir + test -n "$dir"; or return 1 + + set -l rp (path resolve "$dir") + set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-') + set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') + printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") +end diff --git a/functions/_agents_repo_slug.fish b/functions/_agents_repo_slug.fish index 57a4724..d5672f8 100644 --- a/functions/_agents_repo_slug.fish +++ b/functions/_agents_repo_slug.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# DEPENDENCIES +# _agents_repo_local_slug +# # SYNOPSIS # _agents_repo_slug # @@ -63,8 +66,5 @@ function _agents_repo_slug --argument-names dir return 0 end - set -l rp (path resolve "$dir") - set -l base (string lower -- (path basename "$rp") | string replace -ra '[^a-z0-9._-]' '-') - set -l digest (printf '%s' "$rp" | sha256sum | string split -f1 ' ') - printf 'local-%s-%s\n' "$base" (string sub -l 8 -- "$digest") + _agents_repo_local_slug "$dir" end diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index 351e49a..dd3253d 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -5,8 +5,9 @@ # 12-ai-and-developer-tools # # DEPENDENCIES -# _agents_vault_dir, _agents_repo_slug, _agents_repo_ensure_symlink, -# _agents_repo_sync, _agents_repo_install_tools, git, hostname +# _agents_vault_dir, _agents_repo_slug, _agents_repo_local_slug, +# _agents_repo_ensure_symlink, _agents_repo_sync, +# _agents_repo_install_tools, git, hostname # # SYNOPSIS # agents-vault [--link] [--push] [--restore] [--status] @@ -230,17 +231,13 @@ function agents-vault --description 'track curated agent memory in a host-scoped end if test -z "$prev_slug" # No link yet (fresh machine): try the path-derived candidate. - set -l cand_rp (path resolve "$root") - set -l cand_base (string lower -- (path basename "$cand_rp")) - set -l cand_digest (printf '%s' "$cand_rp" | sha256sum | string split -f1 ' ') - set -l cand "local-$cand_base-"(string sub -l 8 -- "$cand_digest") + set -l cand (_agents_repo_local_slug "$root") if test "$cand" != "$slug"; and test -d "$vault/projects/$cand" set prev_slug $cand end end if test -n "$prev_slug"; and test "$prev_slug" != "$slug" - set -l prev_mem "$vault/projects/$prev_slug/claude/memory" set -l cur_content test -d "$vmem"; and set cur_content (command ls -A "$vmem" 2>/dev/null) if test (count $cur_content) -gt 0 @@ -249,7 +246,6 @@ function agents-vault --description 'track curated agent memory in a host-scoped return 1 end test -d "$vmem"; and rm -rf "$vault/projects/$slug" - mkdir -p (path dirname "$vault/projects/$slug") if not git -C "$vault" mv "projects/$prev_slug" "projects/$slug" 2>/dev/null command mv "$vault/projects/$prev_slug" "$vault/projects/$slug"; or return 1 end diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish index a443329..5de4b94 100644 --- a/tests/test-agents-vault.fish +++ b/tests/test-agents-vault.fish @@ -365,6 +365,108 @@ check "ambiguous migration fails" 1 "$arc" check "ambiguous leaves old entry" old (cat $vroot2/agent-vault/projects/$aslug/claude/memory/x.md) check "ambiguous leaves new entry" new (cat $vroot2/agent-vault/projects/git.rootiest.dev-rootiest-clash/claude/memory/y.md) +# Case 2 of the three required cases: the *current* (new-slug) entry is +# present but empty -- neither absent (handled above) nor holding content +# (the ambiguous case above). Migration must still proceed. +set -l emp (new_repo) +set -l emangled (string replace -a '/' '-' -- $emp | string replace -a '.' '-') +mkdir -p $croot2/$emangled/memory +echo precious2 >$croot2/$emangled/memory/keep.md +pushd $emp >/dev/null +agents-vault --silent +set -l eslug (_agents_repo_slug $emp) +popd >/dev/null + +git -C $emp remote add origin https://git.rootiest.dev/rootiest/emptycase.git +set -l enew_slug git.rootiest.dev-rootiest-emptycase +# Pre-create the destination entry as an empty directory -- present, not +# absent -- before migration runs. +mkdir -p $vroot2/agent-vault/projects/$enew_slug/claude/memory + +pushd $emp >/dev/null +set -l erc (agents-vault --silent 2>/dev/null; echo $status) +popd >/dev/null +check "empty-current migration succeeds" 0 "$erc" +check "empty-current migrated content" precious2 (cat $vroot2/agent-vault/projects/$enew_slug/claude/memory/keep.md) +check "empty-current old entry removed" false (test -d $vroot2/agent-vault/projects/$eslug; and echo true; or echo false) + +# Remote-URL-rewrite transition: origin changes from one forge URL to +# another (distinct from adding a remote where none existed). +set -l rw (new_repo https://git.rootiest.dev/rootiest/rewrite-old.git) +set -l rwmangled (string replace -a '/' '-' -- $rw | string replace -a '.' '-') +mkdir -p $croot2/$rwmangled/memory +echo rewrite-precious >$croot2/$rwmangled/memory/keep.md +pushd $rw >/dev/null +agents-vault --silent +popd >/dev/null +set -l rw_old_slug git.rootiest.dev-rootiest-rewrite-old +check "rewrite: old entry populated" rewrite-precious (cat $vroot2/agent-vault/projects/$rw_old_slug/claude/memory/keep.md) + +git -C $rw remote set-url origin https://git.rootiest.dev/rootiest/rewrite-new.git +pushd $rw >/dev/null +agents-vault --silent +popd >/dev/null +set -l rw_new_slug git.rootiest.dev-rootiest-rewrite-new +check "rewrite: migrated to new remote slug" rewrite-precious (cat $vroot2/agent-vault/projects/$rw_new_slug/claude/memory/keep.md) +check "rewrite: old entry removed" false (test -d $vroot2/agent-vault/projects/$rw_old_slug; and echo true; or echo false) + +# Remote-removal transition: origin removed, slug reverts to local-*. +set -l rmv (new_repo https://git.rootiest.dev/rootiest/removeme.git) +set -l rmvmangled (string replace -a '/' '-' -- $rmv | string replace -a '.' '-') +mkdir -p $croot2/$rmvmangled/memory +echo removal-precious >$croot2/$rmvmangled/memory/keep.md +pushd $rmv >/dev/null +agents-vault --silent +popd >/dev/null +set -l rmv_remote_slug git.rootiest.dev-rootiest-removeme +check "removal: remote entry populated" removal-precious (cat $vroot2/agent-vault/projects/$rmv_remote_slug/claude/memory/keep.md) + +git -C $rmv remote remove origin +pushd $rmv >/dev/null +agents-vault --silent +set -l rmv_local_slug (_agents_repo_slug $rmv) +popd >/dev/null +check "removal: migrated to local slug" removal-precious (cat $vroot2/agent-vault/projects/$rmv_local_slug/claude/memory/keep.md) +check "removal: old remote entry removed" false (test -d $vroot2/agent-vault/projects/$rmv_remote_slug; and echo true; or echo false) + +# Fallback-candidate sanitization: a dirty basename (space, !) must produce +# the same local-* slug that _agents_repo_slug would derive, so that a +# lost-symlink recovery (no live link, but the vault entry survives) can +# still find and adopt it. Before the fix, the fallback only lowercased +# the basename instead of sanitizing it like _agents_repo_slug does, so it +# could never match the real entry directory for a name like this. +set -l dirty_root (mktemp -d); set -ga TMPDIRS $dirty_root +set -l dp "$dirty_root/My Project!" +mkdir -p "$dp" +git -C "$dp" init -q +git -C "$dp" config user.email t@t +git -C "$dp" config user.name t +git -C "$dp" config commit.gpgsign false +git -C "$dp" config core.hooksPath /dev/null + +set -l dmangled (string replace -a '/' '-' -- $dp | string replace -a '.' '-') +mkdir -p $croot2/$dmangled/memory +echo "dirty-precious" >$croot2/$dmangled/memory/keep.md + +pushd $dp >/dev/null +agents-vault --silent +set -l dirty_local_slug (_agents_repo_slug $dp) +popd >/dev/null +check "dirty local entry populated" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_local_slug/claude/memory/keep.md) + +# Lose the live symlink, as a fresh-machine restore would, so migration +# must fall back to recomputing the candidate from the path instead of +# reading it off the (now-absent) live symlink. +rm -f $croot2/$dmangled/memory + +git -C $dp remote add origin https://git.rootiest.dev/rootiest/dirty.git +pushd $dp >/dev/null +agents-vault --silent +popd >/dev/null +set -l dirty_new_slug git.rootiest.dev-rootiest-dirty +check "fallback finds sanitized local entry" dirty-precious (cat $vroot2/agent-vault/projects/$dirty_new_slug/claude/memory/keep.md) +check "fallback old entry removed" false (test -d $vroot2/agent-vault/projects/$dirty_local_slug; and echo true; or echo false) + set -e __fish_agent_vault_dir set -e __fish_agent_vault_claude_root