Commit Graph

44473 Commits

Author SHA1 Message Date
kshitijk4poor
a342e6563d fix(file-ops): keep fenced byte-exact reads usable under xtrace
A shell with `set -x` (user rc, BASH_ENV) traces `+ echo <sentinel>` into
the merged output. That line is an extra separator for _split_segments, so
the segment count mismatched and read_file_raw (the V4A/replace write-back
source) failed with "Failed to read file".

_fenced_read now turns xtrace off before the fence; `set +x`'s own trace
goes to the group's discarded stderr.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 02:39:29 +05:30
kshitijk4poor
5486f72988 test(curator): pin the frontmatter-name archived_at lookup in purge
Extends the archived_at test with a legacy archive flattened under its folder name (accelerate) whose record is keyed by the SKILL.md name (huggingface-accelerate). Red on 52a6835e2b^.
2026-09-27 02:33:57 +05:30
kshitijk4poor
f4c50f49ea refactor(curator): read the usage map once per purge
_archived_ts called get_record per archive dir, and each call re-read and
re-parsed .usage.json (N+1 reads). Load the map once before the scan and
index it; the state/archived_at checks read keys that _backfilled never
changes, so behaviour is identical. Also drop the separate _archive_dir
import in favour of the existing skill_usage module import and split the
~130-char conditional.
2026-09-27 02:33:57 +05:30
kshitijk4poor
ad3d4719ce fix(curator): key purge's archived_at lookup by the skill's frontmatter name
The legacy-archive rescue looked up the usage record by the archive dir
name. Older archives were flattened under the DIRECTORY name (`accelerate`
for skill `huggingface-accelerate`, as restore_skill already documents), so
the lookup found no archived record, fell back to the stale dir mtime and
purged a skill the record says was archived today.

Resolve the record key with skill_usage._read_skill_name (the same
frontmatter reader restore_skill uses), falling back to the dir name when
SKILL.md is missing or has no name.
2026-09-27 02:33:57 +05:30
kshitijk4poor
167a2d00ed fix(curator): purge ages archives by the newer of archived_at and dir mtime
Preferring the usage record's archived_at outright let a stale archived_at
beat a fresh dir mtime: set_state only rewrites archived_at on a state
change, so a skill moved out of .archive by hand (record still "archived")
and archived again keeps the old timestamp and is purged at once, even
though archive_skill just stamped a fresh mtime.

Take the newer of the two signals. Legacy archives (stale mtime, fresh
archived_at) still survive, the stale-record case now survives too, and any
disagreement errs toward keeping the archive, which is the no-data-loss
direction for an irreversible purge.
2026-09-27 02:33:57 +05:30
kshitijk4poor
59b77622a9 fix(curator): purge ages by usage archived_at when present
archive_skill now stamps the archive dir mtime, but archives created before
that fix still carry the skill's last-edit mtime, so the first
`hermes curator purge` after upgrading deletes an idle skill archived
yesterday. The usage record already stores archived_at (set_state), so key
the TTL on it and fall back to the dir mtime only when the record is not in
the archived state (collision-suffixed dirs, forgotten records).

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
2026-09-27 02:33:57 +05:30
John Paul Soliva
bade8387c4 fix(curator): age archived skills from archival time so purge honors the TTL
archive_skill moves the skill dir into .archive/ with rename (or shutil.move, which copies the mtime), so the archive keeps the skill's last-edit mtime. `hermes curator purge` ages archives by that mtime, so a long-idle skill archived today was already older than any archive_ttl_days and got purged at once. Stamp the archive dir's mtime when it is archived.

(cherry picked from commit 1f48db8d3727677eac01dcfae70c7e3ee29e1bcd)
2026-09-27 02:33:57 +05:30
kshitijk4poor
4ce6950252 test(agent): fold the emptied-identity cases into one rebuild-once test
The memory-prose variant already fails without either fix; the plain and
matching-reuse cases repeated it or existing coverage.
2026-09-27 02:22:23 +05:30
kshitijk4poor
03bb82f55c fix(agent): read prompt identity lines only from the trailer paragraph
Memory, context files and plugin prose precede the Model:/Provider:/Platform:
trailer and may contain lines of their own with those labels. When the live
value is empty the trailer omits the line, so a prose line stood in for it:
with the identity check now failing closed on an emptied value, that read as
a mismatch on every turn and rebuilt the prompt each time.
2026-09-27 02:22:23 +05:30
kshitijk4poor
a4db03cee9 fix(prompt): stored Model/Provider line with an empty live value is a stale route
Route commits no longer NULL the stored prompt, so _stored_prompt_matches_runtime is the
only rebuild trigger. A model-only request that clears agent.provider skipped the compare
and replayed the previous route's prompt verbatim. The builder omits empty trailer lines,
so the rebuilt prompt matches from the next turn (no rebuild loop); prompts without
identity lines keep reusing.
2026-09-27 02:22:23 +05:30
Gille
a7059225d5 fix(desktop): keep Cloud portal sign-in alive through redirect aborts (#123091)
* fix(desktop): keep portal sign-in alive across redirect aborts

(cherry picked from commit 3de10973b8e234853ddd0a8ed15bdb66722f5728)

* refactor(desktop): share the ERR_ABORTED predicate between both cookie windows

The remote OAuth window (main.ts, #110308) and the portal window
(portal-session.ts) each carried the same inline `code === -3 ||
/ERR_ABORTED/` test. One resolver per policy: extract it to
oauth-navigation.ts so the two windows cannot drift on what counts as a
superseded navigation. Helper and its unit test come from #89377.

Co-authored-by: Matt Earls <matt@marketplacevelocity.com>

---------

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Matt Earls <matt@marketplacevelocity.com>
2026-09-26 20:43:56 +00:00
kshitijk4poor
429f319d45 refactor(agent): one builder for the interrupted_during_api_call exit reason
The summary-interrupt path added a second copy of the string the loop's
interrupt path builds; turn_explainers matches its prefix, so both now
share interrupted_during_api_call_reason().
2026-09-27 02:02:41 +05:30
kshitijk4poor
578bcd2349 fix(agent): end the turn as interrupted when the iteration summary is cancelled
An InterruptedError from the now-interruptible max-iteration summary was
swallowed by handle_max_iterations' broad except and delivered as the
max_iterations_no_summary fallback with interrupted=False, so finalize_turn
cleared the pending interrupt message and CLI/gateway had nothing to requeue.

Propagate the cancellation, drop the unanswered summary nudge, and surface it
in the finalizer exactly like an interrupted loop API call: interrupted=True,
interrupted_during_api_call exit reason, INTERRUPT_WAITING_FOR_MODEL_PREFIX
text, and result["interrupt_message"] preserved.
2026-09-27 02:02:41 +05:30
funky-xamarin
53e212cf59 fix(agent): make iteration summaries use request-local cancellation 2026-09-27 02:02:41 +05:30
kshitijk4poor
e29517479e refactor(onboarding): ask for the plain intro once 2026-09-27 01:55:21 +05:30
kshitijk4poor
15a933865f test(onboarding): both first-contact notes carry the task-first clause
Invariant for the #123987 fix: whether profile_build is "ask" (default,
profile-build offer) or "off" (plain intro), the first-contact note must
tell the model to do a real first-message task before the intro/offer.
2026-09-27 01:55:21 +05:30
kshitijk4poor
08f0f2ab18 refactor(gateway): route first-contact note through first_contact_turn_note
_hmwa_first_contact_notes re-implemented the branch logic of
agent.onboarding.first_contact_turn_note (profile_build mode check,
is_seen, mark_seen, plain-intro fallback) that the TUI already uses, so
the gateway and TUI paths could drift apart. #123987 deduplicated only
the note literal. Call the shared helper instead; it already falls back
to PLAIN_INTRO_NOTE on error, so the local try/except goes away. The
has_any_sessions() gate stays.

Suggested in review of #123987 by jonpol01.
2026-09-27 01:55:21 +05:30
kshitijk4poor
9b41e8ccb3 fix(onboarding): task-first carve-out on the default profile-build directive
#123987 added the "do the task first" carve-out only to PLAIN_INTRO_NOTE,
which is used only when onboarding.profile_build is "off". The default is
"ask", so a fresh default install still received profile_build_directive,
which opens with "After a one-sentence introduction ... OFFER" and lets
the intro/profile offer replace a real first-message task.

Factor the carve-out into TASK_FIRST_CLAUSE and lead both first-contact
notes with it. The consent-gated profile-build steps are unchanged.
2026-09-27 01:55:21 +05:30
engineer
2984bd80e4 fix(gateway): first-turn intro note must not swallow a real task
The zero-session first-contact sidecar note (PLAIN_INTRO_NOTE /
_hmwa_first_contact_notes) unconditionally told the model to just
introduce itself, with no carve-out for the case where the user's
first-ever message IS a real task. On a fresh tenant whose voice task
was the very first message, the model followed the note verbatim and
replied with a static 'I'm Hermes. /help shows the available commands.'
- no tool call, no attempt at the task at all. This is a third shape of
the first-turn-onboarding-hijack class (turn replaced outright, not
just augmented with the known profile-build pitch).

Fix: PLAIN_INTRO_NOTE now instructs the model to do the task first
(call whatever tools it needs) and fold the one-line intro into the
close of that same reply; only a message with no real request gets the
old bare intro behavior. Also de-duplicated the literal note text in
gateway/run_turn.py, which had drifted into an inline copy instead of
importing agent.onboarding.PLAIN_INTRO_NOTE.

(cherry picked from commit fc7c3839b0b6774133d4fe8df59a9e98d23bdd8b)
2026-09-27 01:55:21 +05:30
kshitijk4poor
4f3c1a4f9a chore: map dzianisv for salvage of #123987 2026-09-27 01:55:21 +05:30
teknium1
4127d78da8 test(e2e): TUI /exit re-signals tmux when it leaves the exited pane unreaped
CI diagnostics (tmux 3.4 on ubuntu-24.04) show the /exit hang is tmux's:
after /exit the pane process is a single-threaded zombie of the tmux
server (Threads:1, PPid = tmux server), the server is running with
SIGCHLD caught, not blocked and not pending, and pane_dead_status never
fills for 60s. The TUI had already exited (its epilogue is in the PTY
transcript). A zombie tmux has not reaped after 2s now gets a SIGCHLD
nudge so tmux runs its waitpid loop and reports the real exit status; a
process that is still running is never touched and still times out.
2026-09-26 13:18:16 -07:00
teknium1
13bf2ebaaa test(e2e): TUI clarify cell tolerates the API-only first-contact note; exit failures dump threads + tmux server signals
Main now appends the install's first-contact onboarding note to the first
user message on the wire only (per-turn sidecar, never persisted). The
clarify cell's invariant is that the answer is not a second user turn, so
compare what the user typed. An un-reaped pane zombie on CI now reports
the pane process threads (state/wchan) and the tmux server's signal masks.
2026-09-26 13:18:16 -07:00
teknium1
ceecaa98f1 test(e2e): TUI /exit waits for tmux to reap the pane before reading its exit status
tmux marks a pane dead on pty EOF, which the kernel delivers when the exiting
process closes its last tty fd -- before SIGCHLD lets tmux reap it and fill
pane_dead_status. On a loaded CI runner the harness read the status in that
gap and failed first_exits_clean with an empty '/exit status '. Poll until
tmux reports the exit status or signal, and make every exit failure report
status/signal, raw tmux answer, pane pid state, elapsed time, the frame
before /exit and the tail of a pipe-pane PTY transcript.
2026-09-26 13:18:16 -07:00
teknium1
9acdab8f87 test(e2e): docstring wording 2026-09-26 13:18:16 -07:00
teknium1
f0640122e4 test(e2e): harden Ink TUI tmux suite per review
- wait for the startup session (status bar 'ready') before the first submit;
  PHASE names in harness errors
- known_failure pins (merge-order safe) instead of strict xfail; new cell for
  /resume typed during startup being undone (#121456)
- verbose tool progress so tool output is rendered and checked exactly once
- width cells check the paragraph layout (whole words, rows read on) so a
  stale-width frame goes red on shrink
- tmux socket under the test root (-S), removed on close
- persisted/screen, summary, exit and raw interrupt-partial checks
2026-09-26 13:18:16 -07:00
teknium1
cccadaf170 test(e2e): TUI tmux harness uses a private prebuilt bundle; retry empty tmux queries 2026-09-26 13:18:16 -07:00
teknium1
2ccd6c2616 test(e2e): TUI modals/tool output under resize, CJK + Ctrl+C, compress + resume render-once 2026-09-26 13:18:16 -07:00
teknium1
649a4a305f test(e2e): Ink TUI tmux harness + resize transcript ledger (alt + inline) 2026-09-26 13:18:16 -07:00
kshitijk4poor
393f03dbcb docs(plugins): say dependency dirs are not carried across catalog updates
The catalog guide said every symlink among untracked files stops the update.
Since guard-excluded dirs (.venv/, venv/, node_modules/, tool caches) are now
pruned from the carry, links inside them never stop an update and those dirs
are rebuilt rather than copied. State that exception next to the symlink rule
(the _carry_user_files docstring was updated with the code change).
2026-09-27 01:42:52 +05:30
kshitijk4poor
052d818569 fix(plugins): never walk guard-excluded dirs when carrying user files
The carry walk exempted links under tools.plugin_guard.EXCLUDED_DIRS from
the symlink refusal but still descended into .venv/, node_modules/ and tool
caches and copied every regular file below them. The staged tree got a venv
with pyvenv.cfg but no bin/python and a node_modules without .bin shims; the
carried node_modules also made _refresh_declared_dependencies skip `npm ci`
when the lockfile was unchanged, publishing the broken copy. Base never
carried ignored directories at all.

Prune EXCLUDED_DIRS in the walk's directory filter so the rule lives in one
place, and drop the now-unreachable EXCLUDED_DIRS clause in _user_link.
node_modules stays in _NO_GIT_REVISION_DIRS: _revision_owned_without_git
also uses it for a top-level *file* of that name, so it is not redundant.
The ignored-data test now asserts .venv/ and node_modules/ are not carried
while ignored user data still is.
2026-09-27 01:42:52 +05:30
kshitijk4poor
062a6bfc57 refactor(plugins): one helper attributes scan blocks to preserved user files
64860c9c20 pasted the same try/except around the security scan in
_install_plugin_core and update_plugin, and plugins_transaction reached
into plugins_cmd_install for the private _preserved_files_note.

_scan_merged_tree now lives next to _scan_plugin_tree/PluginScanBlocked
in plugins_cmd and both sites call it once. Message, scan_result and the
chained cause are unchanged (checked for matched, unmatched, empty and
missing-scan_result cases). _preserved_files_note is typed
(PluginScanBlocked, list[str]) and drops the nested getattr/str() guards
for the module's usual `scan_result.findings if scan_result is not None`
form.
2026-09-27 01:42:52 +05:30
kshitijk4poor
7be3b39931 fix(plugins): exempt every guard-excluded dir from the symlinked-user-file refusal
0d3e00f744 made symlinks in a git checkout's untracked/ignored set fail
the update, exempting only node_modules/. An ignored .venv/ or venv/
always holds symlinks (bin/python), so any plugin that keeps a local
virtualenv could no longer be updated from the catalog.

Reuse tools.plugin_guard.EXCLUDED_DIRS (node_modules, .venv, venv,
caches) as the exemption: the guard already treats those as
reproducible artefacts it never scans, and links under them are still
never followed into the staged tree. The ignored-data repin test now
keeps an ignored .venv with a bin/python link and must still update.
2026-09-27 01:42:52 +05:30
kshitijk4poor
5630c223d0 fix(plugins): scan the carried tree once and name preserved files in a block
_install_plugin_core ran the security scan and the portable-package check
on the pristine clone, then ran both again after before_swap had merged in
user files. The second pass existed only because file-count/size limits
apply to the merged tree. before_swap needs only the manifest and the
staged tree, and both exist before the first scan. It now runs there, and
one scan/portable check admits the final bytes. Subdir updates scan once
(probe: 2 scans -> 1, and that scan sees the carried files).

A dangerous finding in carried user data (a cached page, a notes file)
blocked the update with a report that read as if the pristine upstream
revision were malicious. Carry callbacks now return the paths they
preserved. When a scan blocks, the message names the findings that sit in
those files, or, when none of the findings can be matched to them, notes
that the tree included preserved user files. Both the no-git reclone path
and update_plugin's catalog/git carry do this.
2026-09-27 01:42:52 +05:30
kshitijk4poor
03fae2fac8 fix(plugins): refuse symlinked user files on git-checkout updates
cff26600d2 stopped following symlinks when carrying untracked/ignored
files into a staged catalog update: a link planted after the installer's
scan could point outside the plugin root, past the guard. But it did so
by skipping them silently. Base followed the link and kept the content,
so a user whose ignored config.yaml is a link into their dotfiles now
loses that config on repin with no warning. _carry_user_files promises
to fail before publication rather than drop user state.

In a git checkout, symlinked files or dirs in the ??/!! set now fail the
update before publication, and the error names every such path. Links
are still never followed. Links under node_modules/ are .bin shims that
a reinstall recreates, so they stay skipped rather than blocking every
JS plugin's update. The no-git branch is unchanged: there, links may be
upstream's own.

The existing ignored-data-dir git test gains the case: the update
refuses, names data/link.yaml, and the live plugin keeps its revision,
its link and its data. This also gives the no-follow rule a test that
fails if the link is followed.
2026-09-27 01:42:52 +05:30
kshitijk4poor
1362b95c04 fix(plugins): keep dashboard/ and JS module files revision-owned on no-git carry
A subdirectory install has no .git, so the carry cannot tell removed
upstream code from user files and relies on a deny-list. That list missed
the dashboard surface (web_server_dashboard loads dashboard/manifest.json)
and .mjs/.cjs/.jsx/.tsx, so an upstream that dropped its dashboard or a
hook script got the old files back.

Add dashboard/ to _NO_GIT_REVISION_DIRS and the four extensions as a
carry-local set. They stay out of tools.plugin_guard.CODE_FILE_EXTENSIONS
on purpose: that set exempts code files from env-secret scan patterns, so
widening it there would weaken scanning rather than broaden it.

The docs now list what is actually enforced, and the existing subdir
update test pins dashboard/manifest.json + hooks/run.cjs removed upstream
are not resurrected.
2026-09-27 01:42:52 +05:30
kshitijk4poor
3d5ad396fc refactor(plugins): one conflict/skip helper and a pruned no-git carry walk
Gate review of the user-file carry found the destination-conflict message
copied three times, the no-git branch re-running the same junction/dir-clash
checks the shared path already does, two copies of the preserve-skip test,
and a walk that lstat()s every file under node_modules/, desktop/, skills/
and sidecar/ only to throw each one away.

- _conflict(rel, reason) builds every "left unchanged" refusal; the
  destination checks run once for both branches.
- _skip_preserve(name) is shared by _local_changes and _carry_user_files;
  the walk prunes skipped dirs, so the carry checks only the file name.
- The no-git walk prunes top-level _NO_GIT_REVISION_DIRS and classifies
  revision-owned paths before lstat().
- _local_changes returns (None, []) for a no-git tree, so its one caller
  unpacks directly.

No behaviour change.
2026-09-27 01:42:52 +05:30
kshitijk4poor
6e3c933b36 test(plugins): trim carry tests to the two update invariants
Keep the end-to-end invariants only: a no-git subdir install (url and
catalog paths) keeps user config/data across update and does not
resurrect removed code (desktop/ and now server.js, with no gate
monkeypatch), and a git checkout keeps a wholly ignored data/ dir.
Helper-level unit tests of _carry_user_files are dropped per the
<=2 invariant-test budget.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
2026-09-27 01:42:52 +05:30
kshitijk4poor
42f83867f4 fix(plugins): never carry symlinks into the staged tree on git updates
The no-git branch of _carry_user_files skipped symlinks, but the git
branch copied user-dir links verbatim. A link pointing outside the
plugin root would land in the new revision after the installer's first
scan, and the plugin guard skips symlinks. Only regular files are
durable user state, so both branches now carry regular files only.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 01:42:52 +05:30
kshitijk4poor
b2d6fed3f1 fix(plugins): treat every guard code extension as revision-owned on no-git carry
The no-git deny-list only matched `.py`, so a subdir install whose new
revision removed `server.js` / `run.sh` (or any other code file) had the
old copy resurrected into the updated tree. The post-carry re-scan is
pattern-based and cannot reliably block that. Reuse
tools.plugin_guard.CODE_FILE_EXTENSIONS as the single source of truth
for "plugin code" so removed code never survives an update.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 01:42:52 +05:30
JoaoMarcos44
ad7a4e8e53 fix(plugins): harden staged user-state carry
(cherry picked from commit 945d92c489227f0986884bcf35b0150f81e4674a)
2026-09-27 01:42:52 +05:30
Austin Pickett
60696a90ee refactor(plugins): hoist PluginOperationError import and dedupe the dir-clash raise
No behaviour change: one lazy import per function instead of six inline,
a single _dir_clash() builder for the duplicated message, and drop the
redundant '.git' check already covered by _PRESERVE_SKIP.

(cherry picked from commit e77af285a3758c338f88c91748ef385a6df8d871)
2026-09-27 01:42:52 +05:30
JoaoMarcos44
d05b33cb4d fix(plugins): contain user-file carry within staged tree
(cherry picked from commit 97be7ac043a95f20268628e1a3c2e6d22c9c74d1)
2026-09-27 01:42:52 +05:30
JoaoMarcos44
23e678aaf9 test(plugins): cover destructive update paths
(cherry picked from commit 7d5cfb5cff73661fc96a0737c1b187fc4594c8d6)
2026-09-27 01:42:52 +05:30
JoaoMarcos44
3e03da41e1 fix(plugins): carry user files across staged updates
(cherry picked from commit 8b17cdef5a1cb45a5f9f79ec5590376147ffe239)
2026-09-27 01:42:52 +05:30
kshitijk4poor
064f2f6b09 fix(source-check): strip inherited pathspec modes from read-only git probes
GIT_LITERAL_PATHSPECS=1 turns disk-cleanup's ':(literal)' pathspec into filename text, so ls-files misses tracked files and the plugin deletes them.
2026-09-27 01:36:34 +05:30
kshitijk4poor
6e8a0a0f99 test(disk-cleanup): fold the untracked-scratch control into the checkout test
The control test only added one thing over the first new test: a quick()
deletion check for untracked scratch in a git-init'ed HERMES_HOME. It also
passed on base, so it pinned no new behaviour. Save both entries in the
first test's quick() call and assert the tracked file survives while the
scratch beside it is deleted, then drop the control. The stack now adds
one test, and that test is still red on base.

Update test_scratch_outside_git_trees_still_cleaned's docstring. It said
only a .git below HERMES_HOME marks a file git-owned, which stopped being
true once an enclosing repo that tracks the file counts too.
2026-09-27 01:36:34 +05:30
kshitijk4poor
f0df5e05b1 fix(disk-cleanup): harden and gate the per-candidate git tracked check
_git_tracks hand-rolled `git ls-files` without windows_hide_flags, an
isolated git env or stdin=DEVNULL. It runs from the synchronous
post_tool_call hook, so on a windowless Windows host each test_*/tmp_*
candidate could flash a console (#54220/#56747 class), and an inherited
GIT_DIR/GIT_WORK_TREE would point it at the wrong repo. Reuse
hermes_cli.source_check._git_ok, which already does all three and returns
False on any failure. The timeout drops to 5s (ls-files needs no more).

git reads the argument as a pathspec, so an untracked `test_[1].py` or
`tmp_*` glob-matched a tracked sibling and was never cleaned. Prefix it
with `:(literal)`.

Only spawn git when a .git exists at or above HERMES_HOME (HERMES_HOME is
a checkout, or sits inside a dotfiles repo). Without one, no repo can
track the file, so a stock install now does a few stats and skips the
process spawn on every qualifying tool call.

Drop the docstring paragraph that repeated _git_tracks' rationale.
2026-09-27 01:36:34 +05:30
kshitijk4poor
b22c36d3bf test(disk-cleanup): fold late-commit and enclosing-repo cases into one test
Keep the stack at two invariant tests: the tracked-file test now also
covers a file committed after first classification in the same process
and a HERMES_HOME nested in an enclosing repo; the separate stale-cache
test is redundant now that there is no cache.

Co-authored-by: David Crandall <david@convergentdesign.dev>
2026-09-27 01:36:34 +05:30
kshitijk4poor
50ca677d2b fix(disk-cleanup): ask git per candidate so enclosing repos are covered too
The tracked-file guard only ran when HERMES_HOME/.git existed, so a
HERMES_HOME nested in an enclosing repo (a ~/.git dotfiles repo tracking
~/.hermes/scripts/test_x.py) still had that committed file deleted by
quick() - the same bug class the guard was added for.

guess_category only reaches this check for test_*/tmp_* names, so a
per-candidate 'git -C <parent> ls-files --error-unmatch -- <name>' is
cheap. It finds whichever repo encloses the file, and it replaces the two
lru_caches plus the index-stat signature: with no cache there is nothing
that can outlive the index, and ls-files output no longer needs decoding.
An exact tracked check stays safe above HERMES_HOME, unlike the bare .git
probe that was dropped for being too broad.

Co-authored-by: David Crandall <david@convergentdesign.dev>
2026-09-27 01:36:34 +05:30
kshitijk4poor
cc92bea9e3 fix(disk-cleanup): decode ls-files output safely instead of swallowing every error
_git_tracks wrapped the lookup in a bare except Exception. The only real
failure it hid was UnicodeDecodeError from text=True on non-UTF-8 paths in
the ls-files output, which escapes the (OSError, SubprocessError) catch.
Decode with surrogateescape (matching how Python decodes filesystem paths)
and drop the catch-all so real bugs surface.

Co-authored-by: David Crandall <david@convergentdesign.dev>
2026-09-27 01:36:34 +05:30