- MovePlan gains moves[] (file move = 1 relocation), left_behind[] for unindexable files that deliberately stay, prune_dirs[] applied after the batch (remove_dir only succeeds when empty, leftovers keep their home) - plan::dir: nesting/target-exists guards, merged per-file rewrites - apply: atomic N-rename batch (per-file git mv), rollback restores every move, rewrite and created dir newest-first - mv_reject accepts dirs; --json adds moved_files/would_move_files only for real dir moves (single-file contract byte-identical); summary shows (N files) and left-behind count - engine rollback/prune tests moved to tests/apply.rs (public API; a mid-batch failure cannot be forced through the CLI), java-rule e2e split into its own binary to keep every file under the 250-line budget
5.6 KiB
jmove skill (for AI agents)
What this tool does
Moves or renames source files — or whole directories of them — inside a project and updates every import statement referencing them. Never breaks imports. Supported: TypeScript, JavaScript, Java (package declaration + all importers + the file move are kept in sync); Python/Go on the roadmap. Single binary, no LSP needed.
When to use
- The user asks to move/rename/reorganize source files or packages.
- You refactored a directory layout and imports now point at nothing.
- You need to verify the project has no broken imports (
check).
Commands
mv — move a file and rewrite its importers
jmove mv <source> <target> [--root DIR] [--source-root DIR] [--dry-run] [--json] [--no-git]
Always run --dry-run first and confirm the change set looks right.
Moving onto an existing path fails with TARGET_EXISTS — choose another
target (Phase 1 has no overwrite mode).
Directory moves: mv <dir> <target> relocates every indexed file under
<dir> mirrored under <target> in one atomic batch. --json adds
moved_files[] (from/to per file) and left_behind[] — real files under
<dir> that are not indexable and deliberately stay where they are.
Emptied source directories are pruned; a directory with leftovers is not.
A source that is nested in its own target fails with PLAN_REJECTED.
Git integration: inside a git repository, a tracked file is renamed with
git mv so the rename is staged (history-preserving git log --follow /
git diff -M work). Untracked files, non-repositories and --no-git
fall back to a plain filesystem rename. The import rewrites land in the
working tree unstaged either way — stage or commit them yourself.
--json reports the choice as moved_via ("git"/"fs") and, on a
dry-run, would_move_via.
check — find broken imports
jmove check [--root DIR] [--json]
Run after any move (or any edit) to validate project consistency.
fix — auto-repair import problems
jmove fix [--root DIR] [--rule ID] [--dry-run] [--json]
Runs the deterministic rules over the whole project and applies the
repairs through the same atomic engine as mv (dry-run diff, rollback,
exit codes). Current Java rules: java/unused-import (deletes single-type
imports whose name is provably unreferenced), java/missing-import
(inserts the import of a project class used by simple name — unique FQN
candidate required) and java/import-order (Google style: statics first,
then single-type, ASCII-sorted, duplicates dropped). TS/JS:
ts/unused-import deletes whole statements whose every bound name is
unreferenced; mixed statements (one name live) stay untouched because ESM
imports carry module side effects, so partial specifier surgery is
deliberately off. Unknown --rule
fails with INVALID_ARGUMENT and lists the known ids. A candidate the
engine cannot prove safe is reported with "applied": false and a
candidates array of FQN options — resolve it yourself (pick one, add
the import) and re-run; fix never guesses. When two rules want the same
bytes (order rewrite vs an unused deletion), the urgent rule applies and
the other is deferred ("applied": false, "skipped") — re-run fix
until it reports "nothing to change" (converges in 2–3 runs).
Recommended: fix --dry-run --json, inspect files[].fixes[]
(rule, line, message, applied, optional candidates), then apply
without --dry-run. For Java projects the practical loop is: mv →
fix → fix again if anything was "skipped" → check.
Java specifics
- Moving a
.javafile rewrites three coordinated edits: its ownpackagedeclaration, everyimport <fqn>naming the class (static member imports keep their member suffix) and the physical move. - The target must be a
.javapath under the same source root (src/main/java,src, …) and its directory maps to the new package. Anything else exits1with codePLAN_REJECTED. - A class in the default package (no
packagedeclaration) can only be renamed inside its directory. checknever reports unresolved Java imports (jdk, third-party,pkg.*) as broken — they are external by design, like TS bare specifiers.- Monorepos with duplicate packages (
guavavsandroid/guavaunder one--root): the same FQN exists in parallel trees, so resolution picks the sorted-first copy and a move rewrites the wrong files. Pass the global--source-root DIRto index (and fix/move) only that subtree; each tree is then self-contained and correct. Without the flag behavior is unchanged;--source-rooton a non-directory exits 1.
Recommended agent workflow
jmove mv <src> <target> --dry-run --json— preview.- Inspect
affected_files; if unexpected, abort and ask the user. jmove mv <src> <target> --json— apply (atomic, rolls back on error).- Java moves:
jmove fix --json— heal bare same-package references left behind by the move (they breakjavacotherwise). jmove check --json— verify nothing is broken.
Output format (--json)
Every response: { "status": "ok" | "dry_run" | "error", "operation": "...", ... }.
Errors carry a stable code (TARGET_EXISTS, SOURCE_NOT_FOUND, ...)
and a hint describing the next action. On success, mv reports
changed_files with line-level old/new import diffs and counters
(moved, updated_imports).
Exit codes
0— success1— operation failed (read--jsonerror or stderr)2—checkfound broken imports
Rules of use
- Never run
mvwithout a prior--dry-runin the same session. - After every successful
mv, runcheck; treat exit code2as a failed refactor.