Releases: sapn95/git-tidy
Release list
v3.0.8
A # only starts a comment after something that closed (#4)
Two findings CodeRabbit raised on #3, both from that PR's own fixes: a # tested by character rather than by whether anything had closed, and a symlink that resolved to a protected file and so counted as protected itself.
What's Changed
- Rounds 21 and 22: twelve findings by @sapn95 in #3
- A # only starts a comment after something that closed by @sapn95 in #4
Full Changelog: v3.0.7...v3.0.8
v3.0.7
Round twenty: one rule for three quote scanners (#2)
Six findings from review round twenty, plus two from CodeRabbit on the PR.
A hyphen inside a word reopened a quote, and that broke two things: it refused
keep: [rock-'n-roll, build] on every shipped binary (a regression from 3.0.6),
and in - rock-'n-roll # note it left the comment inside the value, so the
clean.keep pattern stopped matching and the directory it was written to protect
was deleted.
_strip_comment, _split_flow and _outside_quotes now share one rule, which is
told whether it is inside a flow collection: , and - are delimiters there
and ordinary characters in a plain block scalar.
Also: number patterns that matched nothing raised a bare ValueError with exit 1
where the man page promises 2; _outside_quotes was not escape-aware; a dry run
promised a quarantine expiry the apply would not do; and a dry-run git gc --auto was filed under "other, see the lines marked -".
The decision diagram and the modes diagram had both fallen behind the code.
What's Changed
Full Changelog: v3.0.6...v3.0.7
v3.0.6
Round nineteen: flow parser, credential refusal, honest gc (#1)
Eight findings from review round nineteen, plus two from CodeRabbit on the PR.
The one that had shipped: 3.0.5 taught _split_flow that a quote only opens where
a value can begin and left the colon out of that list, so after key: every
comma or bracket inside a quoted value read as structure — refused on every
shipped binary, fine from a checkout. _outside_quotes needed the rule too.
doctor --fix: the multi-value refusal sat after the prompt, so a dry run
promised a strip the apply then refused; and one remote with two credentialed
url= lines gave two identical actions and two prompts for one problem.
sync.gc: git gc --auto exits 0 whether or not it repacked, so a workspace
where nothing happened reported "200 repositories packed". It counts loose
objects either side now, and no longer shares its consent key with the
unconditional gc that doctor --fix runs.
From CodeRabbit: url and pushurl are load-bearing for opposite reasons — git
fetches from the first url and pushes to every pushurl — and the safety bullet
still promised a protected file stays at its path, which clean.quarantine
changes.
What's Changed
New Contributors
Full Changelog: v3.0.5...v3.0.6
v3.0.5
git-tidy 3.0.5 — round eighteen
The three worst findings were all in the duplicate-URL tidying added in 3.0.4,
which CodeRabbit had already caught once before it shipped. It is gone.
git fetches from the first url = of a remote, so their order is
load-bearing — and --replace-all collapses every line its pattern matches into
one, which moves the survivor. Two values that stripped to the same thing were
enough to silently repoint a remote at its mirror, with the report saying only
"2 credentials taken out of remote URLs". A failed --add after a successful
--unset-all left the remote with no URL at all, reported as success, exit 0.
And with git before 2.30 the unset was a no-op, so it added a third copy: the
very shape 3.0.4 was released to remove.
A remote with more than one value for a setting is now reported and left alone.
One value is safe to rewrite in place; several are the user's to sort out, and
saying so beats breaking a remote to tidy a duplicate line.
Also:
keep: [Don't Touch/*, build]was refused here and read fine by PyYAML: an
apostrophe opened a quoted run that never closed. _strip_comment has had the
"a quote only opens where a value can begin" rule for rounds; _split_flow did
not, so the two disagreed with each other. Loads from a checkout, fails on
every shipped binary.- A partial thin-out kept its future-tense prediction as its detail, so --json
showed "empty out, keeping id_rsa" beside "applied": true. Only the applied
line had been copied from _rmtree_counting, not the detail line. git gcran without asking under --ask, even in a repository where every
fetch and switch had just been declined, and never appeared in the report
either way. It asks now, and says what it did.- The man page never mentioned clean.quarantine, and named only
clean.ignored_keep where the code also means clean.keep.
Full Changelog: v3.0.4...v3.0.5
v3.0.4
git-tidy 3.0.4 — round seventeen
The worst of it wrote to your .git/config on every run. _configured_urls read
every scope while git config --replace-all writes the local file, so a
credential living in ~/.gitconfig matched nothing local and git appended a new
url= instead of replacing one. The read-back then reported failure, and the next
run did it again — one more line each time, for ever, never mentioned. Both
halves say --local now.
Work that did not happen was reported as done, in two places:
- A thin-out that emptied 100 KB and then hit a permission error returned
applied=False, so the line said "(97.7 KB)" while the summary said nothing was
removed and nothing freed. Its sibling _rmtree_counting had this right. - A directory where everything is protected was called "emptied out" and
counted as one path removed, with no bytes freed, on every run for ever — a
.venv whose only match is a certifi/cacert.pem does exactly that. It says
"kept whole" now, which also stops the quarantine expiry firing on a run that
applied nothing.
YAML, both "loads on every shipped binary, fatal from a checkout":
- A control character inside a comment was never checked, because the comment
was stripped first. PyYAML's Reader runs over the whole stream. ?opens a complex mapping key, which PyYAML refuses in a value position.
The flow-collection path has guarded that for rounds; the block path had not.
Three documents described behaviour the code does not have: trash.sensitive's
comment said protected files are "lifted into quarantine" when they stay exactly
where they are; neither the README nor the man page said that clean.quarantine
changes that, or that the source and certificate exemptions do not apply to
clean.ignored_keep and clean.keep.
Also: two credentialed URLs that strip to the same thing left two identical
lines. Deduplicating them with --unset-all would have cleared the remote's
other URLs too — CodeRabbit caught that in the fix before it shipped, so it is
--fixed-value. And a detached HEAD whose trunk another worktree holds was
counted in the summary as a branch.
Full Changelog: v3.0.3...v3.0.4
v3.0.3
git-tidy 3.0.3 — round sixteen
3.0.2 could hard-delete a certificate. The exemption added there was meant to
apply to trash.sensitive alone, and _protects returned early on that branch and
never reached the clean.ignored_keep line — so a .pem holding no private key
lost its protection, was deleted outright with no quarantine, and did not
appear in the report at all. Three documents promise the opposite in as many
words, one of them the file git-tidy init writes into the user's config.
local_state is checked first now, and unconditionally.
The 64 KB peek made the same hole lose real keys. A haproxy.pem is
cat fullchain.pem privkey.pem, and a long chain puts the key past it: the
file was read, declared harmless and deleted. It reads the whole file now, up
to a ceiling no bundle approaches; anything larger keeps its protection unread.
doctor --fix:
- A remote with two credentialed url values reported the first strip as
failed, because the read-back narrowed by setting rather than by value and
found the second one still there. It sent somebody to hand-edit a file that
was already clean, and exited 1. - A URL stored with surrounding whitespace never matched its own value-pattern,
built from a trimmed copy, so git appended one more url= on every run for
ever. Values are read with -z and untouched now, and --fixed-value is used
where git has it. - A dirty detached HEAD was summarised as "uncommitted changes — left on their
branch", which put it in FORCE_CAN_FIX. --force sets sync.stash, which this
path does not read; in a repository with no remote nothing rescues it, so the
advice was simply wrong. And it is on no branch.
Also: rmtree removes what it can before raising, so a directory that could not
be emptied was reported as a pure failure while three files had really gone.
And PyYAML's Reader refuses a non-printable character anywhere, quoted scalars
included — the check looked outside the quotes only, so \x00 and \x7f loaded on
every shipped binary and were a ReaderError from a checkout.
v3.0.2
git-tidy 3.0.2 — round fifteen
3.0.1 could tell you it had removed a credential when it had not.
git config --replace-all takes a POSIX regex as its third argument, not a
literal, so a password containing a +, ? or * did not match its own value and
git appended instead of replacing. The secret stayed in .git/config, the
remote grew a second push URL, every later run appended another copy, and the
summary said the credential had been taken out. Somebody who read that line and
decided not to rotate the token was worse off than before it ran. The pattern is
anchored and escaped now, and the value is read back before anything is called
applied — this is the one remedy whose silent failure leaves a person believing
they are safe.
Also in --fix:
- Answering
qat the second credential prompt threw away the record of the
first, which had really been rewritten, while the run printed "everything
already done is kept". _check_credentials was the only accumulator in the file
that returned a list instead of filling the caller's. - Both prompts read identically. They name the setting now, url or pushurl.
- A repository with no remote could never have its detached HEAD put back:
default_branch resolves through refs/remotes only, so the refusal said "no
branch to go back to" while local main sat there containing HEAD. A repository
with no remote is where a detached HEAD matters most.
YAML: yesterday's splitlines() → split("\n") traded one wrong set of line breaks
for another. splitlines() broke on \x0b, \x0c and \x1c-\x1e, which PyYAML
refuses; split("\n") ignored \r, \x85 and
, which PyYAML honours — so a
config with a lone CR loaded from a checkout and was refused by every shipped
binary. Exactly PyYAML's set now, and nothing else.
And two false positives visible on a real 256-repository workspace, where
trash.sensitive was holding 2.4 MB back:
- botocore/cacert.pem matched *.pem. It holds 130 public certificates and no
private key. A certificate file is read before it is believed. - eslint's source-code/token-store/ matched token. It is forty .js files. The
source-extension exemption reads a name, and a directory has no extension, so
it could never apply to one — while a mycreds/ holding a password file still
must be kept, and is, because it holds something that is not source.
Smaller: the "held back" tally silently dropped the refusal to move a linked
worktree's HEAD, because NOT_HELD_BACK matched it first. _guarded hardcoded
"origin" when counting unreachable remotes, and could raise a timeout from
inside an except clause.
v3.0.1
git-tidy 3.0.1 — round fourteen, and doctor --fix as it should have shipped
3.0.0 went out an hour ago with a way to lose an uncommitted file.
_detached never called _would_clobber_ignored. is_dirty deliberately does
not look at ignored files, so a local .env was invisible to it, and
git switch replaces one the target branch tracks without a word. In a single
run --fix --apply the tool printed sync's refusal of exactly that switch, then
performed it, then summarised it as held back. All four gates now live in
_cannot_leave_a_detached_head, so there is one list of reasons not to move a
HEAD rather than two.
The same function was missing three more of them, each one already written down
somewhere else in the file:
- a bisect: switching resets HEAD, the BISECT_* files survive, and the next
git bisect goodmarks the trunk tip. _cannot_switch names this case in its
own comment. - a linked worktree, which sync.worktrees: skip exists to leave alone. --fix
overrode the setting silently and left the worktree holding the trunk, which
the main checkout could then never be switched onto. - a trunk that exists only on the remote, reported as "those commits are not in
main" when they were in origin/main and nothing was at risk.
And elsewhere in --fix:
- Answering
qat an --ask prompt threw away the fixes already applied: doctor
had neither keeping() nor reporting(), though it is now the only other step
that writes. - One
aconsented to all three remedies workspace-wide, because all three used
kind "fix" — the exact defect consent_key was written for. git remote get-urlexpands insteadOf, so a credential in ~/.gitconfig was
reported as this repository's and "fixed" by writing the already-clean value
back, for ever. It reads .git/config directly now, which also means pushurl
and second url= values are finally looked at — two secrets that had been
sitting exactly where doctor promises to look.https://ghp_abc@github.com/…, the shape every personal access token is
pasted in, was not recognised at all: the pattern required a colon.
ssh://git@hoststill is not a credential.- "N repositories put right" counted actions, and counted a shared object store
once per worktree.
Yesterday's tab check scanned the raw line including comments, so
jobs: 4 # workers<TAB>(one per core) loaded from a checkout and refused the
whole config on every shipped binary — the same shape it was written to close.
It looks at the line after the comment and before the trailing whitespace now,
and outside quoted scalars, which is what its docstring always said.
CI: the container runs as root, and root ignores permission bits, so the tests
that chmod something are skipped there rather than failing. Two SC2251 in
release.yml.
v3.0.0
git-tidy 3.0.0
Major, because clean means something different now. A directory holding one
protected file used to be moved to quarantine whole — a 400 MB node_modules
renamed into a directory in the same workspace because one file in it was
called tokenizer.js, reclaiming nothing. It is thinned out instead: the
protected entries stay exactly where they are and everything else goes.
Configs that used to be accepted are now refused, all of them cases where the
two YAML parsers disagreed or where the value could only have been a mistake:
a list key with nothing after it, a negative count, a tab outside the
indentation, a flow collection as a mapping key, key:value with no space.
New: doctor --fix, the automatic quarantine expiry, and stopping early when
the network rather than the repository is the problem.