Repository navigation
Releases: iasaeed/xcpurge
Release list
xcpurge 0.6.0
Added
xcpurge purge <root>: one command that does whatassets --delete,files --deleteandstrings --prunedo,
in one run with one confirmation and one trash session (xcpurge restoreundoes it all).--onlynarrows it
to categories or finding kinds (same values as--fail-on, exceptany),--ids <id>,<id>,...acts on
exactly the listed findings whatever their confidence (unknown ids are warned about),--dry-runlists what
would go and changes nothing, and--verifyworks as onassets --delete(pruned strings are not
build-checked). The HTML report's purge selection only offers the kinds purge acts on.- HTML report: bulk selection. A checkbox column with a select-all-visible header checkbox, keyboard
x, and a
bottom bar showing the count, total size and a copyablexcpurge purge <root> --ids ...command. Duplicates and
informational findings are not selectable.
Changed
- New brand mark and icon (docs/brand); the HTML report shows it in the header and as its favicon.
Fixed
- HTML report: scrolling the page over the module graph no longer zooms it, so the page keeps scrolling past the
graph. Zoom with ⌘/Ctrl + scroll or a trackpad pinch; drag to pan and the +/−/1:1 buttons are unchanged.
Install
brew install iasaeed/tap/xcpurge
Or download xcpurge-macos.tar.gz (universal arm64 + x86_64) and verify with xcpurge-macos.tar.gz.sha256.
xcpurge 0.5.0
Added
- New
xcpurge.jsonkeytestFixtureDirectories(default["__Snapshots__", "ReferenceImages", "ReferenceImages_64", "FailureDiffs"], an empty list turns it off): snapshot-test reference images (swift-snapshot-testing,
FBSnapshotTestCase) are loaded by test name, sounused-asset,unused-font,unused-resourceand
orphan-filefindings under those folders are no longer reported, and their files are left out ofduplicate
sets. The files still count inxcpurge sizetree
totals. A non-default list changes the baselineconfigHash; the default does not. README table row:
| testFixtureDirectories | Folder names of snapshot-test reference images; files inside are never reported as unused or orphaned (still counted by xcpurge size). | ["__Snapshots__", "ReferenceImages", "ReferenceImages_64", "FailureDiffs"] |
Changed
-
An exact name in a module the visibility rule does not count (a framework or package the app links, a sibling
target) no longer leaves a finding at high: it caps assets, strings, fonts and resource files at medium (reason
codenot-visible), or at low when it is the argument of a main-bundle lookup (UIImage(named:),Image("…"),
UIColor(named:),NSDataAsset,NSLocalizedString,String(localized:),Text("key"),
Bundle.main.url(forResource:); reason codemain-bundle-lookup). Bundle-less lookups in dependencies of the
owning app were medium and are now low too. Lookups that pass.module/#bundle, and localization calls naming
another table, still never count.bundle: nil/in: nilnow reads as the main bundle. With
--ignore-test-references, names found only in test targets do not count as such a match either. -
A visible use of an accessor whose body in another module holds the name (
CommonStrings.attachmentTypeAnimated
wrapping"ATTACHMENT_TYPE_ANIMATED") counts as a reference, for declarations whose name is declared only once.
Heuristic, without type checking. -
Runtime-built names count from any module, not only visible ones: a constant prefix before an interpolation or
concatenation ("avatar_\(rawValue)", three characters for untyped strings) and a string literal ending in-,
_or.that is a proper prefix of a name ("pause-animation-") make matching assets and resource files low,
with adynamic-lookuprecord. A visible untyped prefix was medium before. -
Cases of
enum E: Stringwithout an explicit raw value are indexed as implicit raw values (new reference kind
implicitRawValueinexplain), soBundle.main.url(forResource: effect.rawValue, …)withcase pullkeeps
pull.wavat medium (reason codeenum-raw-value). -
An asset name computed locally and passed to a lookup (
let name = entity.displayName.lowercased()then
UIImage(named: name),for name in names { Image(name) },Image(name.lowercased())) is a keyless lookup: the
catalogs and loose images of that module drop to medium, with adynamic-lookuprecord. Parameters (forwarding
wrappers, whose callers' literals are indexed), stored properties, plain value reads (model.iconName,
if let name = model.iconName), names bound to literal text anywhere in the file, and calls to a same-file
function whose body holds literals never count.
References inside#if FLAGblocks keep counting whatever the flag (now covered by a test). -
The headline counts only actionable findings. The text
Total:line, the scan summary line and the HTML header
card excludedynamic-lookuprecords andduplicatemembers and name them beside the total instead, e.g.
Total: 4,500 findings (900 high, 2,000 medium, 1,600 low) + 392 dynamic lookups, 88 duplicate sets. The JSON
summarygainsactionableandinformational(dynamicLookups,duplicateSets);summary.countkeeps
counting every finding, as the schema's version policy requires, so usesummary.actionablefor the headline
number.docs/schema/findings-v2.schema.jsondeclares both new keys (optional; reports from 0.4.0 lack them).
byKindis unchanged. Theagent,githubandxcodesummary lines use the same split.
Fixed
-
Plural variants (
dates.time_hours.one,.few, …; any key ending in a CLDR categoryzero,one,two,few,
many,other) are looked up asbase + "." + categoryat runtime: when the base key or a sibling is
referenced, the base is passed to a localization call, or an accessor holding the base (Strings.dates_time_hours)
is used, every high member of the family drops to medium (reason codeplural-family). -
An asset name written with a file extension (
#imageLiteral(resourceName: "clock-12.pdf"),
@"NowPlaying@2x.png";.pdf .png .jpg .jpeg .heic .svg .gif .webp .json .dataset, with@2x/@3x) now counts
as an exact reference to the asset set, and#imageLiteralis an asset lookup. -
Dotted asset names with a digit component (
custom.person.3.circle.fill) were already matched: the nextcloud-ios
finding is correct, since its only use sits inside a/* … */block. A test now covers both cases. -
Ctrl-C or SIGTERM during
swift package dump-packagenow stops the scan at once on repositories with many local
packages: interrupt handlers and the 60 s timers run on their own queue instead of the global pool that blocked
manifest runs could exhaust, and at most onedump-packageper core runs at a time. -
Predicted Xcode asset symbols (used when no
GeneratedAssetSymbols-Index.plistis available) now matchactool
name for name (Xcode 26 and 27): digit-letter boundaries (2fa_icon→._2FaIcon,e2e_test→.e2ETest),
all-caps names kept verbatim (IMG_0042→.IMG_0042,FOO BAR→.FOO_BAR, ObjCACImageName_IMG_0042),
Color/Imagesuffixes dropped whatever the set type and on namespace folders (Brand Color/tint→
.Brand.tint), keyword backticks only where Xcode adds them (`Type`folders, baretype/async/await
leaves), symbol sets with image symbols, and no symbols for data sets, app icons or sprite atlases. ObjC constants
include the namespace (ACImageNameIconsMyTabsHome) and drop non-ASCII letters. When several assets of one
target map to one symbol, only the first name in byte order owns it:.icWalletkeepsIc Walletand no longer
hidesic-walletandic_wallet. -
Predicted string catalog symbols now match
xcstringstool generate-symbolsfor every probed key:typeand
TYPEare.type,protocol/Self/Anyare backticked, apostrophes vanish (don't→.dont),$and
emoji are kept, named placeholders are dropped (%1$(friendsPosts)lld posts→.posts(friendsPosts:)),
%#@files@→._1$Files, and keys Xcode refuses (self,Type,Protocol,%@, unsupported specifiers such
as%x) get none. Amanualkey with"generatesSymbol": falseno longer counts as used through a symbol,
table names starting with a digit get an_(2fa→_2Fa), and backticked symbols (.`default`) match. -
xcpurge explain --format jsonno longer prints"line": 0for a reference inside a file with no line
structure (a value in a.jsonfile):lineis omitted from that reference site, and the text report shows the
bare path. Thelinekey ofreferenceSitesis optional. -
swift package dump-package(the default forPackage.swift; use--no-swiftfor untrusted code, since it
compiles and runs the manifest) now runs in a process group of its own. A timeout or Ctrl-C/SIGTERM kills it with
swift-frontendand the compiled manifest, which used to be left running for minutes. Each manifest gets 60 s
(was 30 s) before it is read as text instead, with awarning: ... timed out after 60 s; using manifest text fallback. -
ghost-referencefindings under an uninitialised git submodule (a.gitmodulespath that is missing or an empty
folder) or under missing CocoaPods output (aPodfilewith noPods/Pods.xcodeprojbeside it) are no longer
reported, and neither are thecannot read xcconfigandno project.pbxprojwarnings for those paths. One note
per folder says what to run:uninitialised git submodule <path>; run git submodule update --initor
CocoaPods output missing; run pod install. -
A 0-byte
.strings,.stringsdictor.xcstringsfile warnsempty strings fileinstead ofcannot parse strings file. Every diagnostic path is root-relative;cannot read xcconfigused to print an absolute path. -
A project target linking a local package product through
XCSwiftPackageProductDependencywith nopackage =
attribute (Xcode writes this for a package added as a folder) got no dependency edge when the product's targets
come from a variable (targets: publicTargets), which--no-swiftcannot read: every non-test target of the
package is now linked, and local packages the project references are searched first. App code in such projects
now sees the package's assets and strings (mastodon-ios). -
Output xcpurge wrote inside the scanned tree (an
--outputHTML or JSON report, a baseline under another name,
asize --savesnapshot such assize-base.json, including ones saved by 0.4.0, or anexplain --format json
file) was read as a reference source, so the next scan or--deletedropped every finding it listed (e.g. the
remaining copy of an asset afterremovetook the other one). Such files and the.xcpurge/and
.xcpurge-trash/folders are now skipped by every walk; size snapshots now carry atoolfield.
Install
brew install iasaeed/tap/xcpurge
Or download xcpurge-macos.tar.gz (universal arm64 + x86_64) and verify with xcpurge-macos.tar.gz.sha256.
xcpurge 0.4.0
Added
-
xcpurge baseline [root] [--output FILE]writes.xcpurge-baseline.json: one sorted line per finding identity
(no line numbers or confidence, root-relative paths, locales collapsed into a count), regenerated in full with a
+N added, -M removedsummary, byte-identical for an unchanged tree on the same day. -
--baseline FILE(also"baseline"inxcpurge.json) onscan,assets,stringsandfileshides
baselined findings as a multiset, printsBaseline FILE (N days): A baselined, B new, C fixed, and applies
--fail-onand--deleteto the new findings only. Fixed entries are scoped to the scanned categories,
--targetsand--exclude;--strict-baselineexits 1 on them. The JSON report carries abaselineobject. -
Baseline warnings for age (
--baseline-max-age, default 90 days), a different scope configuration
(configHash), uncovered categories, a changed--min-confidence, another xcpurge minor version and empty files;
a missing, empty, unparseable or newer-version baseline exits 2. The configured baseline file is dropped from the
scan inventory so it is never read as a reference. -
stale-ignorefindings (new categoryignores, informational) for.xcpurgeignorerules and// xcpurge:ignoremarkers that suppress nothing: path globs matching no path on disk (checked against the inventory,
then one lazy walk of the root that ignores--targets,--excludeand skippedPods/),kind:typos,
module:names no module has,asset:/string:rules with no match (only when that category was scanned;asset:matches loose image and font names with or without extension, catalog names exactly, as suppression does), and
line-style markers that a second detector pass without markers still finds nothing for (never when a dynamic name
or a kept dead-source file is involved). Block-comment markers and markers in generic text files are never
reported; scoped runs (--project,--package,--workspace) keep only the glob andkind:checks. The disk
walk prunes directories no rule can match under, skips build output and is capped (undecided rules are reported
low as "could not evaluate").--no-stale-ignore/"staleIgnores": falseturns it off,--fail-on-stale-ignore
exits 1. -
.xcpurgeignorerules remember their line and text (Ignore.ruleSummaries); the Swift/ObjC tokenizer exposes
ignoreMarkers(line, covered lines,//or/* */style) next to the unchangedignoredLines. -
xcpurge size --compare base.json --format markdown|github: a pull-request comment body that starts with
<!-- xcpurge-size -->(sticky, edited in place), headline### xcpurge size: +X (+Y%), an honest "what was
measured" line (source-tree bytes; install size with--app; download size ≈ .ipa bytes for an .ipa), Base | PR |
Change tables for tree and categories, modules and app sections in<details>, Top changes (--top 20paths
with |change| ≥--min-path-delta 500bytes, asset sets as units, the rest folded), reclaimable estimate and a
footer; capped at 60,000 characters.githubalso appends the body to$GITHUB_STEP_SUMMARY.
--comment-threshold 100KBprints nothing (and writes no--output) below that |change|;--fail-if-grows 500KB|2%exits 1 withsize gate: grew +X (limit Y)after the comment is written;--allow-missing-compare
makes a missing base a note (exit 0). Snapshots gainpaths(per-path bytes, asset sets as units) and
app.compressedBytesfor an .ipa;schemaVersionstays 1. Sample workflow:docs/ci/xcpurge-size.yml
(artifact base, sticky comment deleted when quiet,size-increase-oklabel skips the gate). -
xcpurge init --xcode-phase [--configuration Release|any] [--max-warnings 50]prints a Run Script build phase
(Homebrew/local PATH, skips CI and other configurations,command -vguard that warns instead of failing, stamp
file for incremental skips, sandboxing note); it writes nothing and never edits a project file.--max-warnings N
on--format xcodecaps the warning lines, addswarning: [xcpurge] K more findings not shown (run xcpurge scan for all)and always ends withnote: [xcpurge] T findings (h high, m medium, l low)when there are findings.
Sample workflow:docs/ci/xcpurge.yml(release binary,--format github --baseline ... --fail-on any, no
xcodebuild). -
Reports present baselines and git history: text prints
Baseline: N baselined, M new, K fixed (D days old)plus
warnings (fixed list under--verbose) and· 3y, J. Appleseedafter findings with history;--format github
adds; baseline: ...to its summary notice and one::noticelisting fixed entries; the HTML report gains an
ignorescategory colour, a Baseline card (New / Baselined / Fixed, fixed list, age warning), Age and Author
columns, a History facet andage:>90/author:/bucket:search tokens (only when some finding has
history), a drawer History section (last commit, bucket and dead-since labelled heuristic) andid/ageDays/
authorCSV columns. Author e-mails are never shown. -
--gitannotates findings with their last commit (sha, author name, date, subject), age in days and a
recentlyAdded/leftoverbucket (--git-recent-days, default 30) as an optionalhistoryobject in JSON.
One streaminggit log --name-onlyper repository with early exit; submodules are walked separately. Shallow
clones givestatus: shallowwith a note instead of dates; uncommitted pathsuntracked; a non-git root
not-git.--git-emailsadds author emails (never by default);--git-dead-sinceruns a cappedgit log -S
heuristic for the commit that removed the last use. Age never changes confidence. -
--gitsettles paths the index does not know (git ls-files: never committed, ignored, deleted) asuntracked
before the walk, so they cannot hold it up. A ghost reference is looked up on its own and shows the commit that
deleted the file (note: "deleted in <sha>", that commit aslastCommit; at most 50 per repository). -
--verify "<build command>"onassets --delete,files --deleteandremove(with--verify-timeout,
default 1800 s, and--verify-max-builds, default 8): preflight build (red → exit 4, nothing removed), build
after the removal, restore the units the compiler names, bisect the rest under the cap, full restore and
exit 4 on timeout. Dead source files that reference each other are one removal unit. The report always says
"build-verified, not runtime-verified". Blocked paths persist in.xcpurge-trash/verify-blocked.json
(--deleteskips them,removewarns); the session manifest gains averifyrecord. -
--verifyhandles Ctrl-C / SIGTERM: the build's process group is stopped (not just xcpurge), everything is
restored and the exit code is 4. A red build that no removed file can be blamed for (only project edits left,
"unresolved") restores everything and exits 4 too. Aborts leave a record-only session whoseverify.reason
saystimed out,interruptedorunresolved;restoreskips such sessions by default andrestore --list
marks them "record only". Build commands that leave background children behind end when the shell exits. -
assets --delete/files --delete:size-before.jsonis measured on the ungated scan (before--baseline
hides findings), likesize-after.json, so the reclaimable figure can no longer appear to grow. -
xcpurge restore --path <p>(repeatable) restores part of a session and re-plans theproject.pbxprojedit
for what stays in the trash; a session that still has entries is never deleted. -
--format agent(--agent-limit N): plain, deterministic report for coding agents with one legend, a summary, rows grouped by kind and module (stableid, confidence, reason codes, readyremovecommand and.xcpurgeignoreline), capped per kind with a narrowing hint. -
xcpurge explain <root> <id|path|name>(--format text|json): reported / suppressed (which rule or marker) / belowThreshold / notReported, owners, modules checked, the visibility rule, reference sites, dynamic patterns, the confidence derivation and what would make it safe; exit 2 with suggestions when nothing matches. -
docs/schema/findings-v2.schema.json(JSON Schema draft 2020-12) for the--format jsondocument with a version policy, and theskills/xcpurgeagent skill plus an AGENTS.md snippet in the README.
Changed
-
assets --delete,files --delete,strings --pruneandremovewithout--yesand without a terminal on
stdin: the confirmation cannot be answered, so the three scanning actions still write their report (stdout or
--output) and then exit 2 withNot a terminal; pass --yes to confirm.;removeexits 2 after listing what
it would do. Before, the run ended 0 having done nothing (and, briefly, exited 2 before writing the report). -
--format agent:remove:andignore:are separate lines (theremove:text can be piped to a shell as
is), and theignore:value is the.xcpurgeignoreentry verbatim, quoted only for control characters or
leading/trailing whitespace, so pasting it never produces a rule that matches nothing. -
Text output ends with dimmed
note:lines for info diagnostics (for example the scoped-run stale-ignore
note), orN notes (use --format json to see them)above three;--quietdrops them,--verbosestill lists
them asinfo:. -
JSON findings carry a stable
id(<kind>-<10 hex>, from the finding's identity key and path; no line
number, so it survives edits elsewhere in the file).schemaVersionstays 2: the field is additive, as are the
optionalhistory(with--git) andbaseline(with--baseline) fields.
Fixed
--format agent: project-supplied values (path, name, module, ignore entry) are escaped, so a.stringskey
or asset name containing a newline can no longer forge aremove:lin...
xcpurge 0.3.0
Added
xcpurge remove <root> <path> ...: moves exactly the given files or asset sets to.xcpurge-trashand drops
theirproject.pbxprojreferences, without filtering by finding kind or confidence. A path inside an asset
set removes that (innermost) set; a path missing on disk (aghost-reference) loses only its project
references; plain folders are refused (asset sets, catalogs, bundle-like folders and folder references move
whole); a path outside the root, also through a symlinked folder, or inside the trash is refused before
anything is touched (exit 2); a symlink is moved itself, never its target. Confirms unless--yes;
--quiethides progress. Runs discovery only (no reference index), so it is fast; it does not record the
size before/after table.xcpurge restoreundoes it.ScanEngine.discover(),Trash.targets(for:context:)
andTrash.remove(paths:context:)are the library entry points.- The HTML report's drawer has a Remove from terminal section for file-backed findings: the shell-quoted
xcpurge removecommand with a Copy button, and for duplicates one command per copy in the group (keep one).
Fixed
--delete: a file that one project cannot drop (aproject.xcproj, or a pbxproj line the editor cannot
change) is no longer edited out of the other projects referencing it; it stays untouched everywhere.--delete/remove: an asset catalog given as a whole (App/Assets.xcassets) now loses its pbxproj
reference too.
xcpurge 0.2.0
Added
--format html: a self-contained interactive report written to--outputor
<path>/.xcpurge/report-<timestamp>.html;--openand--browseropen it. The template is compiled into
the binary (make templateregenerates it).CrossProjectfixture: a workspace whose app links and embeds another project's framework.- Live scan progress on stderr when it is a terminal (phases with counts and elapsed time, throttled to ~10
updates a second), ending withScanned N files in M modules in Xs — K findings (h high, m medium, l low).
Never written to stdout; off when stderr is not a terminal, unstyled underNO_COLOR;--quiet/
-qturns it off.ScanEnginetakes an optionalProgressSink. - After writing a report xcpurge prints its absolute path, the findings summary (
No findings.when empty) and
Opened in <app>/Opened in default browser, or a warning withopen's error and the manual
open "<path>"command. - A hint when the scan root holds three or more independent repositories (e.g.
~/Projects). - The HTML report shows "Loading N findings…" in the table until its script has rendered the rows.
xcpurge size: bytes and file counts of the scanned tree by category (asset catalogs, loose images, fonts,
strings, Interface Builder, other resources, source, other files), per asset catalog, per asset set type and
per module, plusEstimated reclaimable: X (high) / Y (high+medium)from the detectors.--appmeasures a
built.appor.ipa(executable,Assets.car, each.lproj,Frameworks/*,PlugIns/*, other
resources);--savewrites a JSON snapshot and--comparediffs against one (before, after, change, %).assets|files --deleteandstrings --prunestoresize-before.json/size-after.jsonin the trash
session and print a before → after table of the categories that changed;restoreprints it reversed.- The HTML report has a Size card (resources, source, whole tree, reclaimable high and high + medium).
- The HTML module graph zooms and pans, and a Show Pods toggle reveals or hides dependency modules; the report
uses a light-only theme. - README rewritten as a guided tour with real examples.
Changed
- JSON reports are
schemaVersion2: they add asizeobject (tree sizes by category and module, reclaimable
estimate). The HTML page still renders schema 1 documents (without the Size card).
Fixed
- Storyboards, xibs, Swift/ObjC sources and plists over 5 MB were skipped, which emptied the storyboard's
object-ID set and letstrings --pruneremove live translations. Only generic text is size-capped now, and a
skipped source caps findings at medium. Text("\(a) of \(b)")now matches the key"%lld of %lld"(all literal segments are compared); format keys
without a match are never high.art.scnassetsnamed inside a path literal, Reality Composer projects used through their generated type,
Core Data models (never high), UIKit's default nib lookup (MyViewController()loadsMyView.xib), and
files inside synchronized folders copied whole (low) are no longer reported as high.- Cross-project links in a workspace (
BUILT_PRODUCTS_DIRproducts, foreign container proxies) make the app a
consumer of the other project's framework. - Only Interface Builder-shaped IDs (
abc-12-def, numbers) make a key an object-ID key;onboarding.title
inOnboarding.stringsis checked against code. --excludeandexcludehide findings but keep excluded files as reference sources; the init template no
longer excludes**/Generated/**.--deletedry-runs the project edit and moves only files whose references can be removed; CRLF pbxproj
files and.xcdatamodeldversion groups are edited;project.xcprojreferences keep their files.- Bundle-less
Image("x")/NSLocalizedStringin a dependency module count (medium) for the app's resources. - Project paths match files case-insensitively on case-insensitive volumes.
- Nested types used unqualified inside their outer type keep their file alive.
- An unterminated string no longer shifts later line numbers; a source folder named
buildis scanned. - After
--delete/--prunethe printed report lists only what is left. - Large JSON/HTML/CSS/JS/xcconfig files are streamed in chunks instead of skipped; only unreadable files (not
under--exclude) cap findings at medium. --deletewrites the backed-up project edit before moving files, restores a reference whose file failed to
move, and matches project paths case-insensitively on case-insensitive volumes.- The HTML report renders module graphs that contain a cycle.
build/holding a package or project is scanned;pubspec.yamlmarks Flutter output; only directories
with exact build-folder names count.- The undo command is printed before the report is written; a failing
--openis a warning; actions that
change nothing leave no empty trash session. --browsernaming an app that is not installed falls back to the default browser and says so, instead of
failing quietly;open's error output is shown when nothing could be opened.
xcpurge 0.1.0
First release.
scan,assets,strings,fileswith text, JSON, Xcode and GitHub Actions output,--fail-on,
--min-confidence,--targets,--exclude,xcpurge.jsonand.xcpurgeignore.- Discovery of classic and synchronized-folder
project.pbxproj, Xcode 27.2project.xcproj(best effort),
Swift packages (with a no-swift fallback) and workspaces; module graph with dependency and embed visibility. - Assets: catalogs, loose images, fonts (PostScript/family/full names), app icon and accent protection,
generated asset symbols (mangling plus DerivedData index), SwiftGen/R.swift accessors, dynamic-name downgrade,
byte-identical duplicates. - Strings:
.xcstrings,.strings,.stringsdict, Settings.bundle, Info.plist keys, storyboard strings,
bundle/table narrowing, generated string symbols, stale keys. - Files: orphans, ghost references, dead source files, unused storyboards/xibs and resources.
- Safe cleanup:
--delete/--pruneact on high-confidence findings by default, move everything into
.xcpurge-trashwith a manifest, andrestoreundoes a session byte for byte. modules,init,doctor.