Skip to content

feat: add CLI#27

Open
jdc-pub wants to merge 31 commits into
mainfrom
feat/cli
Open

feat: add CLI#27
jdc-pub wants to merge 31 commits into
mainfrom
feat/cli

Conversation

@jdc-pub

@jdc-pub jdc-pub commented Jul 3, 2026

Copy link
Copy Markdown
Contributor
  • feat: implement CLI, extend VendorWorktree, and refactor gitattributes helpers
  • test: add integration tests for prepare_merge and track_vendor
  • fix: correct several CLI correctness bugs found in adversarial review
  • feat: make name optional in add, defaulting to URL leaf
  • feat: friendlier CLI UX
  • feat: add apply subcommand
  • fix: read upstream OID from refmap to avoid HEAD-symref misresolution
  • feat: normalize --prefix and stage-only add
  • fix: add missing TLS and reqwest features
  • fix: stage rewritten .gitvendors on remove
  • fix: track vendor paths when add/update conflicts
  • refactor: clarify that staged_attrs_blob only reads the index
  • refactor: simplify check_attr_pattern special-character test
  • refactor: move command logic to binary executor with injectable IO
  • refactor: return blob OID from track_vendor and untrack_vendor
  • fix: force refs/vendor/<name> to a direct ref after fetch
  • fix: peel fetched OID to a commit unconditionally
  • fix: propagate vendor_paths errors in update/apply
  • fix: make advance_head a compare-and-swap on HEAD
  • fix: untrack .gitattributes on remove --keep-files
  • fix: make remove work on an unborn HEAD
  • fix: validate trackable paths before checkout mutates state
  • fix: escape glob metacharacters when writing .gitattributes patterns
  • fix: stop checkout_vendor from clobbering staged index entries
  • fix: guard against silently resolving a .gitattributes merge conflict
  • refactor: move exe.rs unit tests into exe_tests.rs
  • refactor: fold apply into update --no-fetch, stop auto-committing
  • refactor: extract shared merge/entry-resolution helpers in exe.rs
  • fix: prune empty ancestor directories after remove
  • test: record proptest regression seed for vendor_tip

jdc-pub added 30 commits June 9, 2026 08:37
…s helpers

Implements the five top-level subcommands (add, update, status, remove,
list) driven by a clap derive CLI in the new cli.rs.

Two trait changes support the implementation: checkout_vendor now
returns the full overlay tree OID (so callers can mint a commit from the
same tree without re-running the overlay), and prepare_merge is promoted
from an internal exe helper to a first-class VendorWorktree method.

The gitattributes quoting helpers (quote_attr_pattern,
unquote_attr_pattern, find_pattern_end) are replaced with two focused
helpers: check_attr_pattern returns Error::InvalidPath for paths that
would require C-style quoting (space, #, control chars — never seen on
real git source paths), and split_attr_line delegates to
gix_quote::ansi_c::undo for the decode side.  gix-quote is already a
transitive dependency; this makes it direct.  The attributes feature is
enabled on gix for future use.  Unit tests move to src/exe_tests.rs and
use rstest for parameterized cases.

feat: add CLI subcommands add, update, status, remove, list
feat: checkout_vendor returns full overlay tree OID
feat: add prepare_merge to VendorWorktree trait
refactor: replace quoting helpers with check_attr_pattern + split_attr_line
feat: add Error::InvalidPath
chore: add gix-quote direct dep; enable gix attributes feature
Assisted-by: Claude:claude-sonnet-4-6
Covers merge mode, squash mode, and overwrite behaviour in prepare_merge.
Adds a test asserting that track_vendor rejects paths requiring
gitattributes quoting with Error::InvalidPath, replacing the former
path_with_space_is_quoted / idempotent_for_path_with_space tests that
were invalidated when quoting support was removed.

test: add prepare_merge integration tests (merge, squash, overwrite)
test: add path_with_space_returns_invalid_path_error to track_vendor
Assisted-by: Claude:claude-sonnet-4-6
split_attr_line now returns None for comment lines (starting with '#')
and lines with a leading-whitespace separator, matching its documented
contract.

.gitvendors is now written into every commit tree and staged into the
index alongside .gitattributes; previously it was only written to disk,
so collaborators who pulled got vendored files but no config.

On merge conflicts, save_config + stage_gitvendors + prepare_merge are
now called before exit(1), so the user's git commit produces a proper
merge commit and the config entry survives the resolution round-trip.

cmd_add always auto-commits; the message.is_some() gate was removed
because the CLI docs advertise a default message, making the split
behaviour surprising.

cmd_remove now removes deleted vendor file entries from the index after
deleting them from the worktree, so a plain git commit records the
removal rather than leaving the files tracked.

fix: split_attr_line returns None for comment and whitespace-only lines
fix: .gitvendors included in commit tree and staged alongside .gitattributes
fix: conflict path writes MERGE_HEAD and saves config before exiting
fix: cmd_add always auto-commits
fix: cmd_remove stages index deletions for removed vendor files
test: extend split_no_attr_returns_none with comment and whitespace cases
Assisted-by: Claude:claude-sonnet-4-6
git vendor add <url> now derives the vendor name from the last path
component of the URL, stripping .git / .bundle suffixes, matching
the convention used by git clone and git submodule add.

Assisted-by: Claude:claude-sonnet-4-6
Default prefix to vendor/<name>/, rename --ref-name to --ref, add
--dry-run to add and update, and add rm/ls as visible aliases for
remove/list.

feat: default vendored files to vendor/<name>/ when no patterns given
feat: rename --ref-name to --ref
feat: add --dry-run to add and update
feat: add visible aliases rm and ls
Assisted-by: Claude:claude-fable-5[1m]
`git vendor apply [name]` rebuilds vendored files from the recorded
upstream base per the current `.gitvendors` patterns, without fetching.
Editing a vendor's pattern entries and running apply moves or refilters
its files; the change is recorded as a single-parent commit. A vendor
whose files carry local modifications is skipped with a list of the
modified paths unless --force is given, since re-materializing discards
them.

Assisted-by: Claude:claude-fable-5[1m]
When a non-bare local repo fetches with `+HEAD:refs/vendor/<name>` and
shares the upstream's default branch name, gix writes the vendor ref as
a symbolic ref pointing to the local branch.  Re-reading that ref via
`peel_to_id()` returns the local HEAD instead of the upstream tip, and
no upstream objects land in the odb.

The refmap built during the fetch carries the upstream OID (via
`Mapping::remote.peeled_id()`) before any local ref resolution, so
reading from there sidesteps the issue entirely.  The bare-only guard on
the File transport scheme is also removed — it existed only because of
this bug.

Removes `#[should_panic]` from the pinning regression test added in
4c83407.  Upstream: GitoxideLabs/gitoxide#2613.

fix: read upstream OID from outcome.ref_map rather than local vendor ref
fix: allow local (file://) transport for non-bare repos
Assisted-by: Claude:claude-sonnet-4-6
fix: append trailing / to --prefix so it is always treated as a directory
feat: `add` stages vendored files and sets MERGE_HEAD rather than auto-committing; user runs `git commit` to finalize

Assisted-by: Claude:claude-sonnet-4-6
`cmd_remove` wrote the updated `.gitvendors` to the working tree via
`save_config` but never staged it, so the index kept pointing at the old
blob that still listed the removed vendor; the user's next commit would
re-record the deleted entry. Stage the rewritten config like the other
mutating commands do.

Assisted-by: Claude:claude-opus-4-8
The conflict branches of `cmd_add` and `cmd_update` checked out the
conflicted tree and prepared the merge but skipped
`reconcile_tracked_paths`. A path introduced by the merge (e.g. a new
upstream file landing next to a conflict) was therefore never written to
`.gitattributes`, so after the user resolved and committed, later
`status`/`update`/`remove` could not see it. The conflict is in file
content, not in which paths exist, so the mapping can be recorded up front
just as the clean path does.

Assisted-by: Claude:claude-opus-4-8
The helper was named and documented as if it wrote `.gitattributes` ("Write
`content` as a blob, upsert the index entry"), but `track_vendor` already
stages the file and this only reads back the resulting blob OID. Rename it to
`staged_attrs_blob`, correct the doc, and drop the two `cmd_add` calls that
discarded the OID — they asserted a post-condition `track_vendor` already
guarantees.

Assisted-by: Claude:claude-opus-4-8
Use `is_ascii_control()` for the C0-and-DEL range and drop the redundant
tab arm, leaving only the four genuinely special characters in the
`matches!` list.

Assisted-by: Claude:claude-opus-4-8
Splits the binary into a minimal entry point and a dedicated executor
module. All command logic migrates from free functions in `main.rs` to
methods on `Executor` that accept `&mut Io`, enabling output capture for
future tests and alternate frontends. The `VendorWorktree` impl and its
helpers move from the library's `exe` module into `lib.rs` alongside
`VendorRepository`, eliminating the library-side `exe` module. The
worktree test file is renamed to `attr_tests.rs` to match its scope.

refactor: move `VendorWorktree` impl from `exe.rs` into `lib.rs`
refactor: introduce `Executor` struct and `Io` for injectable output
refactor: replace `std::process::exit` on conflict with `ConflictExit`
refactor: rename `exe_tests.rs` to `attr_tests.rs`
Assisted-by: Claude:claude-sonnet-4-6
Both methods now return the OID of the staged `.gitattributes` blob
instead of `()`. `reconcile_tracked_paths` propagates the OID to its
callers, eliminating the `staged_attrs_blob` round-trip that read the
OID back out of the index after the fact.

refactor: `track_vendor` -> `Result<gix::ObjectId, Error>`
refactor: `untrack_vendor` -> `Result<Option<gix::ObjectId>, Error>`
refactor: `stage_gitattributes` -> `Result<gix::ObjectId, Error>`
refactor: `reconcile_tracked_paths` -> `Result<gix::ObjectId>`
refactor: remove `staged_attrs_blob`
Assisted-by: Claude:claude-sonnet-4-6
gixs fetch machinery can write `refs/vendor/<name>` as a symref into
the local branch namespace instead of a direct ref to the fetched
commit, when the remotes HEAD is symbolic and a local branch shares
its target name (gitoxide#2613). `fetch_vendor` already reads the
correct OID from the refmap to work around this for its own return
value, but `vendor_tip`/`vendor_status` read the ref directly and were
still silently resolving the corrupted symref to the local branch tip.

Force-write the ref to the resolved OID after every fetch so all
readers see the same, correct value; this also means the ref never
stores an intermediate tag object for annotated tags, so it always
matches `fetch_vendor`s peeled return value.

fixes: `vendor_tip`/`vendor_status` reading a gix-corrupted local ref
Assisted-by: Claude:claude-sonnet-5
`peeled_id()` on the refmap mapping only returns a peeled value when
the ref advertisement carried one. Fetching an annotated tag by name
gets that advertisement, but fetching by the tag objects own SHA
(`Source::ObjectId`) does not, so `fetch_vendor` could return the raw
tag object instead of its target commit. Downstream code assumes
`entry.base` and the vendor ref always name a commit.

Peel explicitly after the refmap lookup so both fetch paths agree.

fixes: `fetch_vendor` returning an unpeeled tag object for `--ref <sha>`
Assisted-by: Claude:claude-sonnet-5
Both call sites used `.unwrap_or_default()`, so a real error (e.g. an
object-database read failure) was silently treated as "vendor owns no
old paths". `reconcile_tracked_paths` would then never untrack files
the upstream removed, leaving stale `vendor=<name>` lines in
`.gitattributes` that misclassify later user files as vendor-owned.

`tests/vendor_paths/table.rs` (`non_commit_ours_is_error`,
`nonexistent_oid_ours_is_error`) already proves `vendor_paths` returns
`Err` for bad input; a CLI-level repro that reaches this exact call
site is impractical since `current_head` there is always a real HEAD
commit, so no new test is added for the `update`/`apply` path itself.

fixes: `update`/`apply` silently ignoring `vendor_paths` failures
Assisted-by: Claude:claude-sonnet-5
Every caller already knows the exact parent commit it started from, but
advance_head hardcoded `PreviousValue::Any`, so anything else moving
HEAD between the snapshot and the ref write (a hook, a concurrent
process, a slow multi-vendor update loop) got silently overwritten with
no error.

Take the expected parent explicitly and require HEAD to still match it
at write time.

fixes: advance_head force-moving HEAD instead of compare-and-swapping
Assisted-by: Claude:claude-sonnet-5
`untrack_vendor` was called only inside the `!keep_files` branch, so
`--keep-files` left stale `vendor=<name>` lines in `.gitattributes`
even though its own help text promises to remove config and
attribute tracking while leaving the files in place. A later `add` of
the same vendor name would then see the kept files as already
vendor-owned via those stale attributes.

Split the function so untracking always runs when the entry has
tracked paths, and only file/index deletion is gated on `!keep_files`.

fixes: `remove --keep-files` leaving stale `.gitattributes` entries
Assisted-by: Claude:claude-sonnet-5
File deletion, `untrack_vendor`, and index cleanup were all gated on
`repo.head_commit()` succeeding. On an unborn HEAD (`git init; git
vendor add ...` before the first commit), `remove` printed "Removed
vendor" and exited zero, but only the `.gitvendors` entry actually
went away — vendored files stayed on disk, in the index, and in
`.gitattributes`.

`vendor_paths` needs a commit to read a tree from, so add
`resolve_vendor_paths_uncommitted` to resolve the same `vendor=<name>`
attribute selection from the current index instead, and use it when
there is no HEAD commit yet.

fixes: `remove` on an unborn HEAD leaving the vendor in place
Assisted-by: Claude:claude-sonnet-5
`check_attr_pattern` rejects any upstream path that would need
C-style quoting in `.gitattributes`, but it only ran inside
`track_vendor`, which every `add`/`update`/`apply` call site invokes
*after* `checkout_vendor`/`checkout_vendor_conflicted` has already
rewritten the working tree and index. An upstream containing an
unquotable path (e.g. a filename with a space) left vendor files
checked out and staged with no `.gitvendors` entry and no
`.gitattributes` tracking — a half-applied state with no way to
retry, since the vendor could never be added at all.

Expose the check as `validate_trackable_paths` and call it against the
merge/upstream tree's paths before any checkout, in every branch of
`add`, `update`, and `apply`.

fixes: `add`/`update`/`apply` leaving a half-applied checkout on an unquotable path
Assisted-by: Claude:claude-sonnet-5
`check_attr_pattern` rejected whitespace/quote/control characters but
not `*`, `?`, `[`, or a pattern-initial `!`/`#`, and `track_vendor`
wrote the raw upstream path bytes verbatim as the pattern. A file
literally named `a*` became the live glob `vendor/mylib/a*`, matching
unrelated siblings, which `checkout_vendor`'s cleanup or `remove`
could then delete as if they were vendor-owned. A file named `a[0].c`
had the opposite problem: its own generated pattern never matched it.

Escape `*`/`?`/`[`/leading `!`/`#` with a backslash on write, and
unescape symmetrically wherever a parsed pattern is compared back
against a raw path (dedup in `track_vendor`, removal in
`untrack_vendor`) so re-running `update` doesn't see every escaped
line as new and duplicate it.

fixes: unescaped glob metacharacters in generated `.gitattributes` patterns
Assisted-by: Claude:claude-sonnet-5
The on-disk index was rebuilt from `full_tree`, the vendor tree
overlaid onto HEAD's *committed* tree — never the index. Any entry
staged but not yet committed (an addition or a modification) was
silently dropped: `git add newfile.txt` followed by `git vendor
update` unstaged `newfile.txt` with no warning. On an unborn HEAD this
wiped the entire pre-existing index, since there was no committed tree
to overlay onto at all.

Derive the on-disk index from the actual current index instead (or an
empty one if none exists yet), and apply only this vendor's own path
removals/additions to it, leaving every other entry untouched.
`remove_entries`/`dangerously_push_entry` don't invalidate the index's
cached-tree extension, so also drop it explicitly — otherwise a
native `git commit` right after would trust the stale cache and record
the previous tree, silently dropping the vendor's changes.

`full_tree` is still computed and returned unchanged; callers need
that OID to mint commits regardless of what the on-disk index holds.

fixes: `checkout_vendor` dropping staged-but-uncommitted index entries
Assisted-by: Claude:claude-sonnet-5
A vendor pattern that maps an upstream file onto `.gitattributes`
itself (e.g. a root mapping with no destination prefix) can produce a
genuine merge conflict on that path, which `checkout_vendor_conflicted`
correctly splices into the index as unmerged stage 1/2/3 entries. But
`reconcile_tracked_paths` always went on to call `track_vendor`, which
reads and rewrites the (conflict-marker-laden) working copy file, and
`stage_gitattributes`, which unconditionally replaces every existing
stage with a single stage-0 entry — silently resolving the conflict
so `git commit` would succeed with conflict-marker text committed as
normal content.

Check for unmerged stages on `.gitattributes` before writing, and skip
the write entirely when found, leaving the stages exactly as
`checkout_vendor_conflicted` left them and telling the user to resolve
it manually.

This is the most speculative of the ten findings from the adversarial
review (a conflict landing on `.gitattributes` itself requires an
unusual root-mapping pattern); the accompanying test drives the guard
directly with a synthetic conflicted `VendorMerge` rather than
reproducing the full upstream layout end to end.

fixes: silent collapse of an unresolved .gitattributes merge conflict
Assisted-by: Claude:claude-sonnet-5
Matches the existing lib.rs/attr_tests.rs convention: cfg(test)
mod with #[path] pointing at a standalone file, keeping test
code out of the source file.

Assisted-by: Claude:claude-sonnet-5
Neither `git submodule add` nor `git submodule update --remote` commit
on the caller's behalf, so `git vendor` now matches: `update` (fetch
or --no-fetch) always stages its result and tells the user to run
`git commit`, instead of auto-committing multi-vendor runs while
single-vendor runs staged. `apply` duplicated `update`'s "rebuild from
recorded base" logic under a different name, so it's now `update
--no-fetch`.

fix: reject `add` of a vendor name that already exists

Re-running `add` with an existing name silently re-fetched and staged
nothing (an empty "in-progress merge" with no diff), forcing the user
to discover `git merge --abort` on their own. It now errors up front,
same as `git remote add`.

refactor: drop `apply`, `advance_head`, `author_sig`, `committer_sig`
fix: stop multi-vendor `update` from clobbering earlier vendors' pending MERGE_HEAD
fix: reject `add <url> <existing-name>` instead of silently corrupting merge state
Assisted-by: Claude:claude-sonnet-5
`add` and `update` each inlined an identical checkout/reconcile/stage
sequence for applying a merge result; factored into `apply_merge`. The
name-or-all entry lookup repeated in `update`, `update_no_fetch`, and
`status` is now `resolve_entries`. The local-modification diff in
`update_no_fetch` is now `locally_modified_paths`.

Assisted-by: Claude:claude-sonnet-5
`remove` deleted individual vendored files but left now-empty parent
directories behind on disk.

Assisted-by: Claude:claude-sonnet-5
Seed from a run that hit ENOSPC mid-test, not a genuine logic
failure; case now passes and is checked in per convention.

Assisted-by: Claude:claude-sonnet-5
The seed came from a run that hit ENOSPC mid-test, not a genuine
logic failure in the code under test.

Assisted-by: Claude:claude-sonnet-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant