diff --git a/README.md b/README.md index 7de928d..dcbcd2f 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,13 @@ This is where Braid comes into play. Braid makes it easy to vendor in remote git repositories and use an automated mechanism for updating the external library and generating patches to upgrade the external library. -Braid creates a file `.braids.json` in the root of your repository that contains -references to external libraries or mirrors. The configuration allows you to control -aspects of the mirroring process such as; +Braid creates a `.braids.json` file in the repository root. It records named +upstream sources and the local mirrors materialized from each source. The +configuration controls: -* whether the mirror is locked to a particular version of the external library. -* whether the mirror is tracking a tag or a branch. -* whether the mirror includes the entire external library or just a subdirectory. -* whether a subdirectory mirror uses Git partial clone to avoid unrelated blobs. +* whether a source is locked to a revision or tracks a tag or branch; +* which upstream files or directories appear at each local mirror path; +* whether partial clone avoids unrelated blobs while hydrating the source. ## Install @@ -83,7 +82,7 @@ in your next `git commit` unless you unstage them first. - [Shell completion](#shell-completion) - [Output and quiet mode](#output-and-quiet-mode) - [Quick start](#quick-start) -- [Adding mirrors](#adding-mirrors) +- [Adding sources and mirrors](#adding-sources-and-mirrors) - [Checking status and local changes](#checking-status-and-local-changes) - [Pulling mirrors](#pulling-mirrors) - [Syncing mirrors](#syncing-mirrors) @@ -111,9 +110,9 @@ braid add help braid add --help ``` -For an upstream with large blobs outside the mirrored subdirectory, opt into -Git partial clone with `braid add --path ---partial-clone`. This stores `"partial_clone": true` in config version 2 and +For an upstream with large blobs, opt into Git partial clone with +`braid add = --partial-clone`. This stores +`"partial_clone": true` on the source in config version 2 and uses Git's `blob:none` filter for repository-local cache hydration and fetches. The upstream server must support Git object filtering. The setting is ignored when caching is disabled or a global cache is selected. @@ -136,16 +135,16 @@ Bash completion setup, for example: braid completion bash > /usr/local/etc/bash_completion.d/braid ``` -The Bash completion covers global options, commands, command options, and -configured mirror paths. Mirror path candidates are printed relative to the +The Bash completion covers global options, commands, command options, source +selectors, and configured mirror paths. Mirror path candidates are printed relative to the directory where completion is invoked, matching how Braid resolves path arguments. ### Output And Quiet Mode Commands that contact an upstream repository or update the local cache print -start and completion messages on `stderr`, for example fetching a mirror, -updating the cache, checking whether a mirror is current, pushing upstream, or +start and completion messages on `stderr`, for example fetching a source, +updating the cache, checking whether a source is current, pushing upstream, or setting up Braid-managed remotes. In an interactive terminal, long-running operations append `.` about every five seconds and print the completion message on a new line. Non-interactive output is line-based and does not print dots. @@ -154,20 +153,20 @@ Command-requested data stays on `stdout` and is not suppressed by `--quiet`. This includes `status`, `diff`, `help`, and `version` output. Warnings, errors, and recovery or result messages also remain visible under `--quiet`; examples include push provenance warnings, pull conflict instructions, skipped -revision-locked mirror lists, and push stop messages such as "Mirror is not up +revision-locked source lists, and push stop messages such as "Source is not up to date." ### Quick Start -Start anywhere inside an existing Git repository. Add a mirror at the path where -you want the upstream content to live, relative to your current directory: +Start anywhere inside an existing Git repository. Add a named upstream source +and one local mirror: ```bash braid add lib/grit ``` -Braid copies the upstream content into `lib/grit`, records the mirror in -`.braids.json`, and creates a `Braid: Add mirror ...` commit. If you do not +Braid copies the upstream content into `lib/grit`, records the source and mirror +in `.braids.json`, and creates a `Braid: Add source ...` commit. If you do not specify `--branch`, `--tag`, or `--revision`, Braid tracks the upstream repository's default branch. @@ -217,7 +216,7 @@ a different branch or handle each step separately. braid pull lib/grit ``` -### Adding Mirrors +### Adding Sources And Mirrors Add a whole upstream repository: @@ -225,11 +224,13 @@ Add a whole upstream repository: braid add https://github.com/rails/rails.git vendor/rails ``` -Add only a subdirectory or a single file from upstream: +Add several local mirrors from one source snapshot. Omit `=upstream_path` to +mirror the repository root: ```bash -braid add https://github.com/twbs/bootstrap.git vendor/assets/bootstrap --path dist -braid add licenses/PROJECT-LICENSE.txt --path LICENSE.txt +braid add https://github.com/twbs/bootstrap.git --name bootstrap \ + vendor/assets/bootstrap=dist \ + licenses/BOOTSTRAP-LICENSE.txt=LICENSE ``` Track a specific branch or tag: @@ -239,17 +240,23 @@ braid add https://github.com/rails/rails.git vendor/rails --branch 5-0-stable braid add https://github.com/rails/rails.git vendor/rails-7 --tag v7.0.0 ``` -Lock a mirror to an explicit revision when you do not want ordinary +Lock a source to an explicit revision when you do not want ordinary `braid pull` runs to move it: ```bash braid add https://github.com/rails/rails.git vendor/rails --revision 5850a65 ``` -The `local_path` argument is optional. If you omit it, Braid derives the local -path from the upstream repository name, or from the `--path` basename when -mirroring a subdirectory or file, and places that derived path under your current -directory. +The source name is optional and normally derived from the URL basename. With no +mirror arguments, Braid creates one root mirror at that name. Add mirrors later +from the recorded source revision with: + +```bash +braid add :bootstrap docs/bootstrap=docs +``` + +Each mirror argument is `local_path[=upstream_path]`; no `=` means the upstream +root. Local paths cannot contain `=`. Before adding, Braid checks any existing `.braids.json` and requires the target path to be available. Tracked, staged, unstaged, or untracked content at the @@ -262,61 +269,63 @@ they will be included in a normal `git commit`. ### Checking Status And Local Changes -Show every configured mirror, or just one mirror: +Show every configured mirror, one mirror, or every mirror for a source: ```bash braid status braid status vendor/rails +braid status :rails ``` Status output includes the recorded revision and tracking mode, such as -`[BRANCH=main]`, `[TAG=v1.0]`, or `[REVISION LOCKED]`. It also reports useful -state markers: - -- `(Remote Modified)`: the tracked branch or tag points at a different upstream - revision. -- `(Locally Modified)`: the vendored files differ from the recorded upstream - revision. -- `(Removed Locally)`: the configured mirror path is no longer present in the - downstream repository. +`[BRANCH=main]`, `[TAG=v1.0]`, or `[REVISION LOCKED]`. It prints two states as +`(Content State, Source State)`. Content states include `Up To Date`, `Modified +Locally`, `Modified Remotely`, `Removed Locally`, `Removed Remotely`, and +`Modified Locally And Remotely`. Source state is `Current`, `Behind`, or +`Locked`, so a source can be behind while one mirror's content is unchanged. Show local changes as a Git diff: ```bash braid diff braid diff vendor/rails +braid diff :rails braid diff vendor/rails -- --stat braid diff vendor/rails -- --cached ``` +A mirror-path selector diffs only that local mirror. A `:source` selector diffs +every mirror belonging to the source. + Arguments after `--` are passed to `git diff`. This is useful for generating patches, limiting output, or checking staged changes only. ### Pulling Mirrors -Pull one mirror to the newest revision for its tracked branch or tag: +Pull one source to the newest revision for its tracked branch or tag. A mirror +path is an alias for its whole source: ```bash braid pull vendor/rails ``` -Pull every branch and tag mirror in lexicographic mirror path order: +Pull every eligible source in lexicographic source-name order: ```bash braid pull ``` -Revision-locked mirrors are skipped by `braid pull` without a path. When any +Revision-locked sources are skipped by `braid pull` without a selector. When any are skipped, a successful no-path pull prints: ```text -Braid: skipped revision-locked mirrors: - vendor/a - vendor/z +Braid: skipped revision-locked sources: + :a + :z ``` -Explicit-path pulls do not print this skipped-mirror note. Strategy changes -require a local path: +Explicit pulls do not print this skipped-source note. Strategy changes require +a source name or mirror path: ```bash braid pull vendor/rails --revision @@ -324,10 +333,10 @@ braid pull vendor/rails --branch main braid pull vendor/rails --tag ``` -Before pulling, Braid requires `.braids.json` and the mirror path being pulled +Before pulling, Braid requires `.braids.json` and every mirror path in the source to be clean in both the index and working tree. For `braid pull` without a -path, that scoped cleanliness check covers every eligible branch or tag mirror -before any mirror is fetched or updated. +selector, that scoped cleanliness check covers every mirror of every eligible +source before any source is fetched or updated. Use `--no-commit` to stage a pull without creating the automatic Braid commit: @@ -337,10 +346,9 @@ braid pull --no-commit git commit ``` -For no-path `braid pull --no-commit`, eligible mirrors are processed in -lexicographic path order after the initial scoped cleanliness check. If a later -mirror fails or conflicts, earlier staged mirror updates are left in place for -manual resolution. Revision-locked mirrors are still skipped and reported. +For no-path `braid pull --no-commit`, eligible sources are processed in source +name order. Each source resolves one revision and updates all its mirrors as one +aggregate tree merge and one staged transaction. If a pull conflicts with local mirror changes, Braid leaves conflict markers in the mirror working tree, stages the updated `.braids.json`, and writes a @@ -349,7 +357,7 @@ prepared `MERGE_MSG`. Resolve the conflicts, then run the `git add` and `braid pull`. They use this shape: ```bash -git add -- ':(top)vendor/rails' ':(top).braids.json' +git add -- ':(top)vendor/rails' ':(top)licenses/RAILS-LICENSE' ':(top).braids.json' git commit -F '' ``` @@ -360,32 +368,33 @@ mirror path and `.braids.json` from `HEAD`, then remove `.git/MERGE_MSG`. ### Syncing Mirrors -`braid sync` runs the safe push-then-pull workflow for branch mirrors: +`braid sync` runs the safe push-then-pull workflow for branch-tracking sources: ```bash braid sync vendor/rails braid sync vendor/rails vendor/rack ``` -With no paths, `braid sync` selects every configured branch or tag mirror in -lexicographic mirror path order and skips revision-locked mirrors, matching -no-path `braid pull`. When any revision-locked mirrors are skipped, successful +With no selectors, `braid sync` selects every configured branch or tag source in +lexicographic source-name order and skips revision-locked sources, matching +no-selector `braid pull`. When any revision-locked sources are skipped, successful no-path `braid sync` and `braid sync --pull-only` runs print: ```text -Braid: skipped revision-locked mirrors: - vendor/a - vendor/z +Braid: skipped revision-locked sources: + :a + :z ``` -Explicit paths are processed in the order provided, may name branch, tag, or -revision mirrors, and do not print this skipped-mirror note. +Explicit selectors may be `:source` names or mirror paths. Aliases for the same +source are deduplicated, selected sources run in source-name order, and explicit +selection does not print the skipped-source note. Before any fetch, push, editor, worktree write, config write, or pull commit, `sync` checks unresolved Git operation state, `.braids.json`, and every selected mirror path for index and working tree changes. Dirty mirrors outside an -explicit selection do not block that explicit sync. Ignored-only files under a -selected mirror do not block the default check. +explicit selection do not block that explicit sync. Ignored files under a +selected mirror block unless `--autostash` preserves them. Use `--autostash` when selected mirror paths have uncommitted work that should be carried across the sync: @@ -405,7 +414,7 @@ autostash is created. Autostash does not push uncommitted mirror changes. The push phase still uses only the mirror content recorded in downstream `HEAD`. When sync pushes a -changed branch mirror, each upstream push uses the same commit-message review +changed branch source, each upstream push uses the same commit-message review flow described for `braid push`, including optional generated-message prompts when configured. @@ -417,24 +426,24 @@ index state. If automatic restoration succeeds but the saved stash cannot be dropped safely, Braid leaves your restored work in place, keeps the stash recoverable, and tells you to inspect `git stash list` before manual cleanup. -The default push phase only auto-pushes branch-tracking mirrors with committed -local mirror changes. Branch mirrors without committed local changes are skipped +The default push phase only auto-pushes branch-tracking sources with committed +local mirror changes. Branch sources without committed local changes are skipped quietly and still update normally, even if upstream has moved. Selected tag or -revision mirrors with committed local changes stop the sync because `sync` has +revision-locked sources with committed local changes stop the sync because `sync` has no `--branch`; run `braid push --branch ` for that explicit push intent, or rerun with `--pull-only` if you only intended to pull. -If a changed branch mirror's upstream branch moved since the recorded revision, +If a changed branch source's upstream branch moved since the recorded revision, `sync` fails before any mirror is pushed. Pull first, resolve conflicts if needed, commit, then rerun `braid sync`. If the selected mirror path itself was deleted from downstream `HEAD`, `sync` also fails rather than trying to push the deletion of the mirror root; deletions inside an existing mirror directory are ordinary local mirror changes. -`sync` pushes mirrors sequentially. If an earlier mirror push succeeds and a -later mirror's generator or commit editor fails, the earlier upstream commit may +`sync` pushes sources sequentially. If an earlier source push succeeds and a +later source's generator or commit editor fails, the earlier upstream commit may already exist and the pull phase is skipped. Rerun `braid sync` after resolving -the failure to record downstream mirror revisions. +the failure to record downstream source revisions. Use `--pull-only` to run only the pull phase with the same scoped precheck: @@ -452,9 +461,9 @@ braid sync vendor/rails --keep ### Pushing Local Changes Upstream -`braid push` creates an upstream commit from the mirror content recorded in your -downstream `HEAD`, opens Git's commit editor for the upstream commit message, and -pushes that commit. +`braid push` reconstructs one upstream tree from every mirror in the selected +source as recorded in downstream `HEAD`, opens Git's commit editor for one +upstream commit message, and pushes that commit. For non-interactive pushes, pass the upstream commit message explicitly: @@ -509,21 +518,21 @@ message file, or writes only whitespace, Braid opens the normal editor template with commented diagnostics and provenance guidance when available. Those comments are stripped from the final commit message if left in place. -For branch mirrors, pushing without `--branch` targets the tracked branch: +For branch-tracking sources, pushing without `--branch` targets the tracked branch: ```bash braid push vendor/rails ``` -Use `--branch` to push to a different upstream branch, or when the mirror tracks +Use `--branch` to push to a different upstream branch, or when the source tracks a tag or fixed revision: ```bash braid push vendor/rails --branch myproject_customizations ``` -Braid stops without pushing if the upstream branch has moved since the recorded -mirror revision. In that case, pull the mirror first, resolve any conflicts, +Braid stops without pushing if the destination branch differs from the recorded +source revision. In that case, pull the source first, resolve any conflicts, and then push. ### Removing Mirrors @@ -534,8 +543,16 @@ Remove a mirror from the downstream repository: braid remove vendor/rails ``` -Braid removes the vendored content, updates `.braids.json`, and creates a -`Braid: Remove mirror ...` commit. +Braid removes that local mirror while retaining other mirrors in its source. +Removing the last mirror removes the source. Remove a source and all its local +mirrors explicitly with: + +```bash +braid remove :rails +``` + +The automatic commit identifies either the removed mirror and source or the +removed source. Use `--no-commit` to stage the removal without committing: @@ -555,16 +572,17 @@ stages the mirror content removal and `.braids.json` update. Braid creates Git remotes as needed and removes them when the command finishes. -The local cache is enabled by default. Without overrides, Braid stores -repository-local per-mirror bare caches under `.git/braid/cache`. These caches +The local cache is enabled by default. Without overrides, Braid stores one +repository-local bare object cache per upstream URL under `.git/braid/cache`. +These caches are implementation state and can be rebuilt by `braid add`, `braid status`, `braid pull`, `braid diff`, `braid push`, or `braid sync` while the upstream still serves the recorded revisions from `.braids.json`. Repository-local caches are shallow for common branch, tag, and full-SHA revision workflows. Fetching from a shallow cache can make the downstream Git -repository report as shallow because Git records the shallow mirror commits in -`.git/shallow`; those commits are Braid mirror objects, not the downstream +repository report as shallow because Git records shallow source objects in +`.git/shallow`; those commits are Braid source objects, not the downstream branch history. If an upstream has removed a recorded revision and the repository-local cache was deleted, Braid fails instead of guessing a base. @@ -575,7 +593,7 @@ caching. The old `BRAID_LOCAL_CACHE_DIR` environment variable and `--cache-dir` flag have been replaced by `BRAID_GLOBAL_CACHE_DIR` and `--global-cache-dir`. -Mirror paths stored in `.braids.json` always use repo-root-relative `/` +Local mirror paths stored in `.braids.json` always use repo-root-relative `/` separators, and ordinary Braid output uses those same repo-root-relative paths. CLI mirror path arguments may use `/` or `\`; Braid resolves them relative to the directory where the command was invoked, then normalizes them before config @@ -590,10 +608,29 @@ diff arguments after `braid diff ... --` are passed through as raw `git diff` arguments from the process directory; Braid only anchors its own internal mirror pathspecs. -The `--path` option is an upstream Git path and should use Git's `/` separator. -When adding from a local Windows repository path such as `C:\src\upstream.git`, -Braid keeps that original upstream value for Git and derives the default mirror -path from the repository basename. +Upstream paths in `local_path=upstream_path` use Git's `/` separator. When +adding from a local Windows repository path such as `C:\src\upstream.git`, +Braid preserves the fetch target and derives the default source/mirror name from +the repository basename. + +Config version 2 records named sources and their mirrors: + +```json +{ + "config_version": 2, + "sources": { + "replicant": { + "url": "https://github.com/replicant4j/replicant.git", + "branch": "master", + "revision": "18480c9dc34f948218a0c15370712d27b2626fa0", + "mirrors": { + "licenses/replicant-LICENSE.txt": "LICENSE.txt", + "vendor/libs/replicant": "" + } + } + } +} +``` Braid validates configured mirror paths for cross-platform safety. It does not preflight every file inside the selected upstream tree; if an upstream filename @@ -608,13 +645,13 @@ includes it. | Command | Purpose | | --- | --- | -| `add [--no-commit]` | Add a branch, tag, or revision mirror and create or stage the initial Braid change. | +| `add [--no-commit]` | Add a source with mirrors, or add mirrors to an existing `:source`. | | `status` | Show whether mirrors have remote, local, or removal changes. | | `diff` | Show local mirror changes, with Git diff arguments after `--`. | -| `pull [--no-commit]` | Pull one mirror, or every branch/tag mirror when no path is given. | -| `push` | Push committed local mirror changes upstream. | -| `sync [local_path...] [--pull-only] [--autostash] [--keep]` | Push changed branch mirrors, then pull selected mirrors. | -| `remove [--no-commit]` | Remove mirrored content and config. | +| `pull [--no-commit]` | Pull one source atomically, or every eligible source. | +| `push` | Push a source's committed mirror changes upstream as one commit. | +| `sync [selector...] [--pull-only] [--autostash] [--keep]` | Push then pull selected sources. | +| `remove [--no-commit]` | Remove a mirror by path or a source by `:name`. | | `version` | Print the Braid version. | | `completion bash` | Print the Bash completion script. | diff --git a/docs/migration-from-ruby-braid.md b/docs/migration-from-ruby-braid.md index cde922f..13b600b 100644 --- a/docs/migration-from-ruby-braid.md +++ b/docs/migration-from-ruby-braid.md @@ -13,8 +13,8 @@ Comparison baseline: - Initial Go-port migration notes: . -The Go tool keeps the core modern Braid model: mirrors are recorded in -`.braids.json`, mirror content is copied into the downstream repository, and the +The Go tool records named upstream sources in `.braids.json`, with one or more +local mirrors materialized from each source. Mirror content is copied into the downstream repository, and the main workflows are `add`, `status`, `diff`, `pull`, `push`, and `remove`. `pull` is the documented spelling for updating mirror content; `update` and `up` are accepted aliases. The differences below are the parts most @@ -94,14 +94,15 @@ Migration impact: Ruby Braid has no `sync` command. Current Go Braid adds: ```bash -braid sync [local_path...] [--pull-only] [--autostash] [--keep] +braid sync [selector...] [--pull-only] [--autostash] [--keep] ``` The default `sync` workflow pushes committed local changes for branch-tracking -mirrors and then pulls the downstream mirror to the new upstream revision. -`--pull-only` skips the push phase and only pulls. With no paths, `sync` selects -all branch and tag mirrors in lexicographic path order and skips revision-locked -mirrors, matching no-path `pull` selection. +sources and then pulls every mirror in each source to the new upstream revision. +`--pull-only` skips the push phase and only pulls. With no selectors, `sync` +selects all branch and tag sources in lexicographic source-name order and skips +revision-locked sources, matching no-selector `pull` selection. A selector may +be `:source` or one of its mirror paths; aliases are deduplicated. `--autostash` is path-scoped to selected mirrors. It saves selected mirror-path tracked changes, untracked files, ignored files, and selected-path index state, @@ -115,7 +116,7 @@ Migration impact: downstream recorded revision" workflow. - Use `sync --pull-only` as a stricter, scoped pull workflow when you do not want any push attempt. -- Do not expect `sync` to push tag or revision mirrors with local changes unless +- Do not expect `sync` to push tag-tracking or revision-locked sources with local changes unless you use `braid push --branch ` explicitly. ### `push` is more explicit about committed changes and commit messages @@ -127,8 +128,8 @@ tool keeps that model but tightens the user-facing behavior: mirror edits are ignored for push planning; commit them downstream first. - If there are no committed local mirror changes in `HEAD`, Go Braid prints `Braid: No local changes found in downstream HEAD. Stopping.` -- For branch mirrors, omitting `--branch` pushes to the tracked branch. Tag and - revision mirrors still require an explicit `--branch`. +- For branch-tracking sources, omitting `--branch` pushes to the tracked branch. + Tag-tracking and revision-locked sources still require an explicit `--branch`. - The editor starts with commented provenance guidance when Braid can compute downstream commits that touched the mirror since the recorded upstream state. - `push --message ` uses the supplied upstream commit message directly @@ -157,33 +158,34 @@ Migration impact: | Cache flags | Environment variables only | Global `--no-cache` or `--global-cache-dir ` before the command, plus environment variables | Put cache flags before the command name. | | Bash completion | No documented generated completion command | `braid completion bash` prints a Bash completion script | Load it from shell startup or install it in the Bash completion directory to complete global options, commands, command options, and configured mirror paths. | | `update --head` | Accepted as an option, then errors with a deprecation message | Unknown flag for `pull` and its aliases | Remove it; use `--branch`, `--tag`, or `--revision` with an explicit mirror path. | -| `update` without path | Updates all configured mirrors through Ruby's all-update flow | `pull` without a path updates branch/tag mirrors in lexicographic path order, skips revision-locked mirrors, and reports skipped paths; `update` and `up` behave the same way | Locked mirrors are no longer touched by all-update. | +| `update` without path | Updates all configured mirrors through Ruby's all-update flow | `pull` without a selector updates branch/tag sources in lexicographic source-name order, skips revision-locked sources, and reports skipped names; `update` and `up` behave the same way | Locked sources are no longer touched by all-update. | | Strategy flags with no-path `update` | Accepted by the Ruby parser, with inconsistent all-mirror behavior | Rejected for no-path `pull` and its aliases | Pass a local path when changing branch, tag, or revision. | | `diff` pass-through | Arguments after `--` are passed to `git diff` | Same | Output formatting is not exact Ruby text parity. | ## Configuration Compatibility -The Go tool supports only the modern `.braids.json` shape with -`config_version: 2` and a `mirrors` object keyed by local mirror path. Version 1 -JSON config can be migrated with `braid upgrade-config`. +The Go tool supports the named-source `.braids.json` shape with +`config_version: 2`, a top-level `sources` object, and a local-to-upstream +`mirrors` object inside each source. Version 1 JSON config can be grouped and +migrated with `braid upgrade-config`. -Supported mirror attributes remain: +Supported source attributes are: - `url` - `branch` - `tag` -- `path` - `revision` -- `partial_clone` (optional; requires `path`) +- `partial_clone` (optional) +- `mirrors` (required and non-empty) Important differences: - Legacy `.braids` YAML/PStore config is not read or upgraded. - Older `.braids.json` layouts without `config_version` are not upgraded. - `upgrade-config` does not migrate legacy `.braids` YAML/PStore data. -- Unknown root fields and unknown mirror fields fail validation. -- Missing `config_version`, missing `mirrors`, missing `url`, missing - `revision`, or a mirror with both `branch` and `tag` fails validation. +- Unknown root and source fields fail validation; mirror values must be strings. +- Missing `config_version`, missing `sources`, missing `mirrors`, missing `url`, missing + `revision`, or a source with both `branch` and `tag` fails validation. - The current Go code no longer has a special legacy `.braids` diagnostic. A project that still has only `.braids` will look like it has no Go-readable `.braids.json`. @@ -201,15 +203,15 @@ Migration impact: ## Path Handling The Go tool validates paths earlier and more strictly for cross-platform safety. -Configured local mirror paths and upstream `path` values must use `/` +Configured local mirror paths and upstream mirror values must use `/` separators and must not contain parent traversal, empty elements, absolute paths, Windows drive paths, colon characters, or path elements ending in a space or dot. Local mirror paths also reject `.git` and Windows reserved basenames. CLI local path arguments may use `/` or `\`; Braid normalizes them before lookup or storage. Absolute local path arguments are accepted only when they are -inside the downstream worktree. The upstream `--path` value is a Git path and -should use `/`. +inside the downstream worktree. The upstream side of +`local_path=upstream_path` is a Git path and should use `/`. Migration impact: @@ -224,9 +226,9 @@ Both tools enable a local mirror cache by default, but the cache layout changed. Ruby Braid defaults to `~/.braid/cache` and derives cache child paths by sanitizing the upstream URL. Current Go Braid defaults to repository-local -per-mirror bare caches under `.git/braid/cache` and derives cache child paths -from a SHA-256-derived key covering the upstream URL, local path, upstream path, -and tracking mode. +per-URL bare object caches under `.git/braid/cache` and derives cache child +paths from a SHA-256-derived key covering the normalized upstream URL. Sources +with the same URL share objects while retaining source-scoped refs. Current controls: @@ -243,9 +245,9 @@ Migration impact: - Repository-local caches are disposable. If one is deleted, Braid can rebuild it only while upstream still serves the recorded revisions from `.braids.json`. Shallow repository-local caches can also make Git report the downstream - repository as shallow because Braid mirror commits are recorded in + repository as shallow because Braid source objects are recorded in `.git/shallow`. -- Tag mirrors work when the Go cache is disabled. Ruby Braid could not retrieve +- Tag-tracking sources work when the Go cache is disabled. Ruby Braid could not retrieve tag revisions with the cache disabled. ## Pull and Conflict Handling @@ -253,7 +255,7 @@ Migration impact: Non-conflicting pulls still create Braid commits such as: ```text -Braid: Update mirror 'vendor/example' to 'abcdef0' +Braid: Update source 'example' to 'abcdef0' ``` Conflict handling differs in details. Ruby Braid documented that users should @@ -303,7 +305,7 @@ Known output differences include: Ruby's `Braid: Error: ...` style. - `status` prints mirror lines without Ruby's top-level "Listing all mirrors" banner. -- `diff` still labels all-mirror output with `Braid: Diffing `, but exact +- `diff` still labels all-mirror output with `Braid: Diffing mirror `, but exact separators and paging behavior are not a compatibility target. - Verbose Git tracing uses Go's deterministic argv representation and is incompatible with `--quiet`. diff --git a/integration/conflict_test.go b/integration/conflict_test.go index 7551cfe..4742fdd 100644 --- a/integration/conflict_test.go +++ b/integration/conflict_test.go @@ -38,7 +38,7 @@ func TestExecutableUpdateConflictWritesMergeMessage(t *testing.T) { update := runBraid(t, env, downstream, braid, "--quiet", "update", "vendor/basic") assertExit(t, update, 0) assertEmpty(t, "conflict update stderr", update.stderr) - assertContains(t, update.stdout, "CONFLICT: vendor/basic/README.md") + assertContains(t, update.stdout, " vendor/basic/README.md") assertContains(t, update.stdout, "Braid: warning: unrelated staged changes are present") assertContains(t, update.stdout, "git add -- ':(top)vendor/basic' ':(top).braids.json'") assertContains(t, update.stdout, "git commit -F '.git/MERGE_MSG'") @@ -46,15 +46,15 @@ func TestExecutableUpdateConflictWritesMergeMessage(t *testing.T) { assertContains(t, conflicted, "<<<<<<<") assertContains(t, conflicted, "local") assertContains(t, conflicted, "remote") - assertContains(t, readFile(t, downstream, ".git/MERGE_MSG"), "Braid: Update mirror 'vendor/basic' to '"+shortRevision(remoteRevision)+"'") + assertContains(t, readFile(t, downstream, ".git/MERGE_MSG"), "Braid: Update source 'upstream' to '"+shortRevision(remoteRevision)+"'") assertConfigRaw(t, downstream, map[string]configMirror{ "vendor/basic": {URL: upstream, Branch: "main", Revision: remoteRevision}, }) - if unmerged := strings.TrimSpace(gitOK(t, env, downstream, "ls-files", "-u").stdout); unmerged != "" { - t.Fatalf("unmerged entries = %q, want marker fallback without unmerged entries", unmerged) + if unmerged := strings.TrimSpace(gitOK(t, env, downstream, "ls-files", "-u").stdout); len(strings.Split(unmerged, "\n")) != 3 { + t.Fatalf("unmerged entries = %q, want stages 1, 2, and 3", unmerged) } - if cached := strings.Fields(gitOK(t, env, downstream, "diff", "--cached", "--name-only").stdout); strings.Join(cached, "\n") != ".braids.json\nstaged.txt" { - t.Fatalf("cached names = %#v, want config and staged unrelated file", cached) + if cached := strings.Fields(gitOK(t, env, downstream, "diff", "--cached", "--name-only").stdout); strings.Join(cached, "\n") != ".braids.json\nstaged.txt\nvendor/basic/README.md" { + t.Fatalf("cached names = %#v, want config, staged unrelated file, and conflicted mirror", cached) } if got := strings.TrimSpace(gitOK(t, env, downstream, "show", ":staged.txt").stdout); got != "staged content" { t.Fatalf("staged blob = %q, want staged content", got) @@ -94,15 +94,15 @@ func TestExecutableSubdirectoryConflictRecoveryCommands(t *testing.T) { update := runBraid(t, env, workDir, braid, "--quiet", "update", "../../vendor/basic") assertExit(t, update, 0) assertEmpty(t, "subdir conflict stderr", update.stderr) - assertContains(t, update.stdout, "CONFLICT: vendor/basic/README.md") + assertContains(t, update.stdout, " vendor/basic/README.md") assertContains(t, update.stdout, "git add -- ':(top)vendor/basic' ':(top).braids.json'") assertContains(t, update.stdout, "git commit -F '../../.git/MERGE_MSG'") - assertContains(t, readFile(t, downstream, ".git/MERGE_MSG"), "Braid: Update mirror 'vendor/basic' to '"+shortRevision(remoteRevision)+"'") + assertContains(t, readFile(t, downstream, ".git/MERGE_MSG"), "Braid: Update source 'upstream' to '"+shortRevision(remoteRevision)+"'") writeFile(t, downstream, "vendor/basic/README.md", "resolved\n") gitOK(t, env, workDir, "add", "--", ":(top)vendor/basic", ":(top).braids.json") gitOK(t, env, workDir, "commit", "-F", "../../.git/MERGE_MSG") - assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update mirror 'vendor/basic' to '"+shortRevision(remoteRevision)+"'") + assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update source 'upstream' to '"+shortRevision(remoteRevision)+"'") assertFile(t, downstream, "vendor/basic/README.md", "resolved\n") assertConfigRaw(t, downstream, map[string]configMirror{ "vendor/basic": {URL: upstream, Branch: "main", Revision: remoteRevision}, diff --git a/integration/lifecycle_test.go b/integration/lifecycle_test.go index 3adec04..4d53226 100644 --- a/integration/lifecycle_test.go +++ b/integration/lifecycle_test.go @@ -11,7 +11,7 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { env := newProcessEnv(t, root) braid := braidBinary(t) - upstream := filepath.Join(root, "upstream repo") + upstream := filepath.Join(root, "upstream-repo") initRepo(t, env, upstream) writeFile(t, upstream, "lib dir/component.txt", "base\n") writeFile(t, upstream, "lib dir/kept.txt", "kept\n") @@ -28,23 +28,24 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertResult(t, version, 0, "braid "+expectedBraidVersion()+"\n", "") localPath := "vendor/lib with spaces" - remote := remoteName("main", localPath) + sourceName := "upstream-repo" + remote := remoteName("main", sourceName) cacheRemoteURL := repositoryCachePath(t, downstream, localPath, configMirror{URL: upstream, Branch: "main", Path: "lib dir"}) - add := runBraid(t, env, downstream, braid, "add", upstream, localPath, "--path", "lib dir") + add := runBraid(t, env, downstream, braid, "add", upstream, localPath+"=lib dir") assertExit(t, add, 0) assertEmpty(t, "add stdout", add.stdout) assertProgress(t, add.stderr, - "Braid: detecting default branch for mirror "+localPath, - "Braid: updated cache for mirror "+localPath, - "Braid: fetched mirror "+localPath, + "Braid: detecting default branch for source :"+sourceName, + "Braid: updated cache for source :"+sourceName, + "Braid: fetched source :"+sourceName, ) assertFile(t, downstream, "vendor/lib with spaces/component.txt", "base\n") assertFile(t, downstream, "vendor/lib with spaces/kept.txt", "kept\n") assertConfigRaw(t, downstream, map[string]configMirror{ localPath: {URL: upstream, Branch: "main", Path: "lib dir", Revision: baseRevision}, }) - assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Add mirror '"+localPath+"' at '"+shortRevision(baseRevision)+"'") + assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Add source '"+sourceName+"' at '"+shortRevision(baseRevision)+"'") assertNoRemote(t, env, downstream, remote) assertPathExists(t, filepath.Join(cacheRemoteURL, "HEAD")) assertClean(t, env, downstream) @@ -52,8 +53,8 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { status := runBraid(t, env, downstream, braid, "status", localPath) assertExit(t, status, 0) assertProgress(t, status.stderr, - "Braid: updated cache for mirror "+localPath, - "Braid: fetched mirror "+localPath, + "Braid: updated cache for source :"+sourceName, + "Braid: fetched source :"+sourceName, ) assertContains(t, status.stdout, localPath+" ("+baseRevision+") [BRANCH=main]") assertNoRemote(t, env, downstream, remote) @@ -71,10 +72,10 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { push := runBraid(t, pushEnv, downstream, braid, "push", localPath) assertExit(t, push, 0) assertProgress(t, push.stderr, - "Braid: updated cache for mirror "+localPath, - "Braid: fetched mirror "+localPath, - "Braid: pushing mirror "+localPath, - "Braid: pushed mirror "+localPath, + "Braid: updated cache for source :"+sourceName, + "Braid: fetched source :"+sourceName, + "Braid: pushing source :"+sourceName, + "Braid: pushed source :"+sourceName, ) assertContains(t, push.stdout, "Executable push") assertFile(t, upstream, "lib dir/component.txt", "local\n") @@ -90,16 +91,16 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertExit(t, update, 0) assertEmpty(t, "update stdout", update.stdout) assertProgress(t, update.stderr, - "Braid: updated cache for mirror "+localPath, - "Braid: fetched mirror "+localPath, - "Braid: checked mirror "+localPath, - "Braid: updated mirror "+localPath+" to "+shortRevision(pushedRevision), + "Braid: updated cache for source :"+sourceName, + "Braid: fetched source :"+sourceName, + "Braid: checked source :"+sourceName, + "Braid: updated source :"+sourceName+" to "+shortRevision(pushedRevision), ) assertFile(t, downstream, "vendor/lib with spaces/component.txt", "local\n") assertConfigRaw(t, downstream, map[string]configMirror{ localPath: {URL: upstream, Branch: "main", Path: "lib dir", Revision: pushedRevision}, }) - assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update mirror '"+localPath+"' to '"+shortRevision(pushedRevision)+"'") + assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update source '"+sourceName+"' to '"+shortRevision(pushedRevision)+"'") assertNoRemote(t, env, downstream, remote) assertClean(t, env, downstream) @@ -107,7 +108,7 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertResult(t, remove, 0, "", "") assertPathMissing(t, downstream, localPath) assertConfigRaw(t, downstream, map[string]configMirror{}) - assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Remove mirror '"+localPath+"'") + assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Remove source '"+sourceName+"'") assertNoRemote(t, env, downstream, remote) assertClean(t, env, downstream) } @@ -163,7 +164,7 @@ func TestPartialCloneSubdirectoryLifecycle(t *testing.T) { writeFile(t, downstream, "README.md", "downstream\n") commitAll(t, env, downstream, "initial") - result := runBraid(t, env, downstream, braid, "add", upstream, "vendor/wanted", "--path", "wanted", "--partial-clone") + result := runBraid(t, env, downstream, braid, "add", upstream, "vendor/wanted=wanted", "--partial-clone") assertExit(t, result, 0) assertFile(t, downstream, "vendor/wanted/file.txt", "one\n") assertConfigRaw(t, downstream, map[string]configMirror{ @@ -218,7 +219,7 @@ func TestPartialCloneRejectsUpstreamWithoutFilterSupport(t *testing.T) { writeFile(t, downstream, "README.md", "downstream\n") commitAll(t, env, downstream, "initial") - result := runBraid(t, env, downstream, braid, "add", upstream, "vendor/wanted", "--path", "wanted", "--partial-clone") + result := runBraid(t, env, downstream, braid, "add", upstream, "vendor/wanted=wanted", "--partial-clone") assertExit(t, result, 1) assertContains(t, result.stderr, "does not support partial clone filtering") assertClean(t, env, downstream) diff --git a/integration/no_commit_test.go b/integration/no_commit_test.go index 5afac7a..c86d45a 100644 --- a/integration/no_commit_test.go +++ b/integration/no_commit_test.go @@ -34,7 +34,7 @@ func TestExecutableNoCommitAddUpdateRemoveWorkflow(t *testing.T) { add := runBraid(t, env, downstream, braid, "add", upstream, "vendor/basic", "--no-commit") assertExit(t, add, 0) assertContains(t, add.stdout, "Braid: warning: unrelated staged changes are present") - assertContains(t, add.stdout, "Braid: staged add of mirror 'vendor/basic'") + assertContains(t, add.stdout, "Braid: staged add of source ':upstream'") assertFile(t, downstream, "vendor/basic/README.md", "base\n") assertConfigRaw(t, downstream, map[string]configMirror{ "vendor/basic": {URL: upstream, Branch: "main", Revision: baseRevision}, @@ -57,7 +57,7 @@ func TestExecutableNoCommitAddUpdateRemoveWorkflow(t *testing.T) { updateHead := gitOutput(t, env, downstream, "rev-parse", "HEAD") update := runBraid(t, env, workDir, braid, "update", "../../vendor/basic", "--no-commit") assertExit(t, update, 0) - assertContains(t, update.stdout, "Braid: staged update of mirror 'vendor/basic'") + assertContains(t, update.stdout, "Braid: staged update of source ':upstream'") assertFile(t, downstream, "vendor/basic/README.md", "updated\n") assertConfigRaw(t, downstream, map[string]configMirror{ "vendor/basic": {URL: upstream, Branch: "main", Revision: updateRevision}, @@ -68,7 +68,7 @@ func TestExecutableNoCommitAddUpdateRemoveWorkflow(t *testing.T) { } commitAll(t, env, downstream, "combined update") - remote := remoteName("main", "vendor/basic") + remote := remoteName("main", "upstream") gitOK(t, env, downstream, "remote", "add", remote, repositoryCachePath(t, downstream, "vendor/basic", configMirror{URL: upstream, Branch: "main"})) writeFile(t, downstream, "remove-staged.txt", "remove staged\n") gitOK(t, env, downstream, "add", "remove-staged.txt") @@ -76,7 +76,7 @@ func TestExecutableNoCommitAddUpdateRemoveWorkflow(t *testing.T) { remove := runBraid(t, env, downstream, braid, "remove", "vendor/basic", "--keep", "--no-commit") assertExit(t, remove, 0) assertContains(t, remove.stdout, "Braid: warning: unrelated staged changes are present") - assertContains(t, remove.stdout, "Braid: staged removal of mirror 'vendor/basic'") + assertContains(t, remove.stdout, "Braid: staged removal of source ':upstream'") assertPathMissing(t, downstream, "vendor/basic") assertConfigRaw(t, downstream, map[string]configMirror{}) assertRemoteURL(t, env, downstream, remote, repositoryCachePath(t, downstream, "vendor/basic", configMirror{URL: upstream, Branch: "main"})) @@ -121,9 +121,9 @@ func TestExecutableNoCommitPullAllAndDirtyBlocker(t *testing.T) { pull := runBraid(t, env, downstream, braid, "pull", "--no-commit") assertExit(t, pull, 0) assertNotContains(t, pull.stdout, "Braid: warning: unrelated staged changes are present") - assertContains(t, pull.stdout, "Braid: staged update of mirror 'vendor/a'") - assertContains(t, pull.stdout, "Braid: staged update of mirror 'vendor/b'") - assertContains(t, pull.stdout, "Braid: skipped revision-locked mirrors:\n vendor/locked\n") + assertContains(t, pull.stdout, "Braid: staged update of source ':upstream-a'") + assertContains(t, pull.stdout, "Braid: staged update of source ':upstream-b'") + assertContains(t, pull.stdout, "Braid: skipped revision-locked sources:\n :upstream-locked\n") assertConfigRaw(t, downstream, map[string]configMirror{ "vendor/a": {URL: upstreamA, Branch: "main", Revision: aRevision}, "vendor/b": {URL: upstreamB, Branch: "main", Revision: bRevision}, @@ -173,14 +173,14 @@ func TestExecutableNoCommitPullConflictMatchesExistingRecovery(t *testing.T) { update := runBraid(t, env, downstream, braid, "--quiet", "pull", "vendor/basic", "--no-commit") assertExit(t, update, 0) assertEmpty(t, "conflict update stderr", update.stderr) - assertContains(t, update.stdout, "CONFLICT: vendor/basic/README.md") + assertContains(t, update.stdout, " vendor/basic/README.md") assertContains(t, update.stdout, "Braid: warning: unrelated staged changes are present") assertContains(t, update.stdout, "git add -- ':(top)vendor/basic' ':(top).braids.json'") assertContains(t, update.stdout, "git commit -F '.git/MERGE_MSG'") - assertNotContains(t, update.stdout, "Braid: staged update of mirror") + assertNotContains(t, update.stdout, "Braid: staged update of source") assertContains(t, readFile(t, downstream, "vendor/basic/README.md"), "<<<<<<<") - assertContains(t, readFile(t, downstream, ".git/MERGE_MSG"), "Braid: Update mirror 'vendor/basic' to '"+shortRevision(remoteRevision)+"'") - assertCachedNames(t, env, downstream, ".braids.json", "staged.txt") + assertContains(t, readFile(t, downstream, ".git/MERGE_MSG"), "Braid: Update source 'upstream' to '"+shortRevision(remoteRevision)+"'") + assertCachedNames(t, env, downstream, ".braids.json", "staged.txt", "vendor/basic/README.md") if got := gitOutput(t, env, downstream, "rev-parse", "HEAD"); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } diff --git a/integration/scoped_state_test.go b/integration/scoped_state_test.go index 56230d3..886c7c8 100644 --- a/integration/scoped_state_test.go +++ b/integration/scoped_state_test.go @@ -48,7 +48,7 @@ func TestExecutableScopedAddPreservesUnrelatedState(t *testing.T) { assertContains(t, status, "A staged.txt") assertContains(t, status, " M tracked.txt") assertContains(t, status, "?? untracked.txt") - assertNoRemote(t, env, downstream, remoteName("main", "vendor/basic")) + assertNoRemote(t, env, downstream, remoteName("main", "upstream")) } func TestExecutableScopedRemovePreservesUnrelatedState(t *testing.T) { @@ -113,7 +113,7 @@ func TestExecutableScopedAddRemovePrecheckBlockers(t *testing.T) { }, dirty: func(t *testing.T, downstream string) { t.Helper() - writeFile(t, downstream, ".braids.json", "{\"config_version\":2,\"mirrors\":{}}\n") + writeFile(t, downstream, ".braids.json", "{\"config_version\":2,\"sources\":{}}\n") }, args: []string{"add", "$upstream", "vendor/basic"}, wantErr: "local changes are present in .braids.json", @@ -287,6 +287,6 @@ func TestExecutableUpdateAllScopedPrecheckStopsBeforeUpdates(t *testing.T) { "vendor/a": {URL: upstreamA, Branch: "main", Revision: aBase}, "vendor/b": {URL: upstreamB, Branch: "main", Revision: bBase}, }) - assertNoRemote(t, env, downstream, remoteName("main", "vendor/a")) - assertNoRemote(t, env, downstream, remoteName("main", "vendor/b")) + assertNoRemote(t, env, downstream, remoteName("main", "upstream-a")) + assertNoRemote(t, env, downstream, remoteName("main", "upstream-b")) } diff --git a/integration/subdirectory_test.go b/integration/subdirectory_test.go index e01ad80..8f42adf 100644 --- a/integration/subdirectory_test.go +++ b/integration/subdirectory_test.go @@ -27,7 +27,7 @@ func TestExecutableSubdirectoryLifecycle(t *testing.T) { } localPath := "apps/web/vendor/basic" - remote := remoteName("main", localPath) + remote := remoteName("main", "upstream") add := runBraid(t, env, workDir, braid, "--quiet", "add", upstream, "vendor/basic") assertResult(t, add, 0, "", "") assertFile(t, downstream, "apps/web/vendor/basic/README.md", "base\n") @@ -107,8 +107,8 @@ func TestExecutableNoPathCommandsFromSubdirectoryRemainRepositoryWide(t *testing diff := runBraid(t, env, workDir, braid, "--quiet", "diff") assertExit(t, diff, 0) assertEmpty(t, "no-path diff stderr", diff.stderr) - assertContains(t, diff.stdout, "Braid: Diffing vendor/a") - assertContains(t, diff.stdout, "Braid: Diffing third_party/b") + assertContains(t, diff.stdout, "Braid: Diffing mirror vendor/a") + assertContains(t, diff.stdout, "Braid: Diffing mirror third_party/b") assertContains(t, diff.stdout, "a local") assertContains(t, diff.stdout, "b local") } diff --git a/integration/support.go b/integration/support.go index 1de5d49..bd5eaff 100644 --- a/integration/support.go +++ b/integration/support.go @@ -7,11 +7,14 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "os" "os/exec" + "path" "path/filepath" "runtime" "sort" + "strconv" "strings" "testing" "time" @@ -276,7 +279,16 @@ func writeConfig(t *testing.T, repo string, mirrors map[string]configMirror) { type configFile struct { ConfigVersion int `json:"config_version"` - Mirrors map[string]configMirror `json:"mirrors"` + Sources map[string]configSource `json:"sources"` +} + +type configSource struct { + URL string `json:"url"` + Branch string `json:"branch,omitempty"` + Tag string `json:"tag,omitempty"` + Revision string `json:"revision"` + PartialClone bool `json:"partial_clone,omitempty"` + Mirrors map[string]string `json:"mirrors"` } type configMirror struct { @@ -290,7 +302,32 @@ type configMirror struct { func expectedConfigRaw(t *testing.T, mirrors map[string]configMirror) string { t.Helper() - data, err := json.MarshalIndent(configFile{ConfigVersion: 2, Mirrors: mirrors}, "", " ") + sources := map[string]configSource{} + used := map[string]int{} + keys := make([]string, 0, len(mirrors)) + for local := range mirrors { + keys = append(keys, local) + } + sort.Strings(keys) + groups := map[string]string{} + for _, local := range keys { + m := mirrors[local] + key := strings.Join([]string{strings.TrimRight(m.URL, `/\`), m.Branch, m.Tag, m.Revision, strconv.FormatBool(m.PartialClone)}, "\x00") + name := groups[key] + if name == "" { + name = strings.TrimSuffix(path.Base(strings.ReplaceAll(strings.TrimRight(m.URL, `/\`), `\`, "/")), ".git") + used[name]++ + if used[name] > 1 { + name = fmt.Sprintf("%s-%d", name, used[name]) + } + groups[key] = name + sources[name] = configSource{URL: strings.TrimRight(m.URL, `/\`), Branch: m.Branch, Tag: m.Tag, Revision: m.Revision, PartialClone: m.PartialClone, Mirrors: map[string]string{}} + } + s := sources[name] + s.Mirrors[local] = m.Path + sources[name] = s + } + data, err := json.MarshalIndent(configFile{ConfigVersion: 2, Sources: sources}, "", " ") if err != nil { t.Fatalf("marshal expected config: %v", err) } @@ -312,7 +349,7 @@ func assertConfigRaw(t *testing.T, repo string, mirrors map[string]configMirror) if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("parse .braids.json: %v", err) } - if parsed.ConfigVersion != 2 || len(parsed.Mirrors) != len(mirrors) { + if parsed.ConfigVersion != 2 || len(parsed.Sources) == 0 && len(mirrors) > 0 { t.Fatalf(".braids.json semantic parse = %#v, want version 2 and %d mirrors", parsed, len(mirrors)) } } @@ -330,8 +367,9 @@ func mirrorCacheID(localPath string, m configMirror) string { } else if m.Tag != "" { tracking = m.Tag } - parts := []string{m.URL, localPath, m.Path, tracking, m.Branch, m.Tag} - sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + _ = localPath + _ = tracking + sum := sha256.Sum256([]byte(strings.TrimRight(m.URL, `/\`))) return hex.EncodeToString(sum[:16]) } diff --git a/integration/sync_test.go b/integration/sync_test.go index 89f5dcb..84e0f53 100644 --- a/integration/sync_test.go +++ b/integration/sync_test.go @@ -33,12 +33,12 @@ func TestExecutableSyncPushesThenUpdates(t *testing.T) { sync := runBraid(t, syncEnv, downstream, braid, "sync", "vendor/basic") assertExit(t, sync, 0) assertProgress(t, sync.stderr, - "Braid: updated cache for mirror vendor/basic", - "Braid: fetched mirror vendor/basic", - "Braid: pushing mirror vendor/basic", - "Braid: pushed mirror vendor/basic", - "Braid: checked mirror vendor/basic", - "Braid: updated mirror vendor/basic", + "Braid: updated cache for source :upstream", + "Braid: fetched source :upstream", + "Braid: pushing source :upstream", + "Braid: pushed source :upstream", + "Braid: checked source :upstream", + "Braid: updated source :upstream", ) assertContains(t, sync.stdout, "Executable sync") @@ -48,8 +48,8 @@ func TestExecutableSyncPushesThenUpdates(t *testing.T) { assertConfigRaw(t, downstream, map[string]configMirror{ "vendor/basic": {URL: upstream, Branch: "main", Revision: pushedRevision}, }) - assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update mirror 'vendor/basic' to '"+shortRevision(pushedRevision)+"'") - assertNoRemote(t, env, downstream, remoteName("main", "vendor/basic")) + assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update source 'upstream' to '"+shortRevision(pushedRevision)+"'") + assertNoRemote(t, env, downstream, remoteName("main", "upstream")) assertClean(t, env, downstream) } @@ -79,10 +79,10 @@ func TestExecutablePushProvenanceTemplateTouchesGitDefaultTemplate(t *testing.T) push := runBraid(t, pushEnv, downstream, braid, "push", "vendor/basic") assertExit(t, push, 0) assertProgress(t, push.stderr, - "Braid: updated cache for mirror vendor/basic", - "Braid: fetched mirror vendor/basic", - "Braid: pushing mirror vendor/basic", - "Braid: pushed mirror vendor/basic", + "Braid: updated cache for source :upstream", + "Braid: fetched source :upstream", + "Braid: pushing source :upstream", + "Braid: pushed source :upstream", ) template := readFile(t, root, filepath.Base(capture)) @@ -121,7 +121,7 @@ func TestExecutableSyncPullOnlyUpdatesWithoutEditor(t *testing.T) { "vendor/basic": {URL: upstream, Branch: "main", Revision: remoteRevision}, }) assertLatestCommit(t, env, upstream, defaultName+" <"+defaultEmail+">", "remote update") - assertNoRemote(t, env, downstream, remoteName("main", "vendor/basic")) + assertNoRemote(t, env, downstream, remoteName("main", "upstream")) assertClean(t, env, downstream) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 07207da..bd598b4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -33,14 +33,20 @@ type GlobalOptions struct { } type AddOptions struct { - URL string + URL string + SourceName string + ExistingSource string + Mirrors []MirrorMapping + Branch string + Tag string + Revision string + NoCommit bool + PartialClone bool +} + +type MirrorMapping struct { LocalPath string - Branch string - Tag string - Revision string - RemotePath string - NoCommit bool - PartialClone bool + UpstreamPath string } type UpgradeConfigOptions struct{ NoCommit bool } @@ -319,10 +325,10 @@ func parseGlobal(args []string, global *GlobalOptions) ([]string, error) { func parseAdd(args []string, options *AddOptions) error { var positionals []string err := parseCommandArgs(CommandAdd, args, []flagSpec{ + valueFlag("--name", "", "name", func(value string) { options.SourceName = value }), valueFlag("--branch", "-b", "branch", func(value string) { options.Branch = value }), valueFlag("--tag", "-t", "tag", func(value string) { options.Tag = value }), valueFlag("--revision", "-r", "revision", func(value string) { options.Revision = value }), - valueFlag("--path", "-p", "path", func(value string) { options.RemotePath = value }), boolFlag("--no-commit", "", func() { options.NoCommit = true }), boolFlag("--partial-clone", "", func() { options.PartialClone = true }), }, func(pos []string, _ []string) { @@ -331,7 +337,7 @@ func parseAdd(args []string, options *AddOptions) error { if err != nil { return err } - if err := requireArgRange("add", positionals, 1, 2); err != nil { + if err := requireArgRange("add", positionals, 1, -1); err != nil { return err } if options.Tag != "" && options.Branch != "" { @@ -340,12 +346,38 @@ func parseAdd(args []string, options *AddOptions) error { if options.Tag != "" && options.Revision != "" { return usageError("add cannot combine --tag and --revision") } - if options.PartialClone && options.RemotePath == "" { - return usageError("add --partial-clone requires --path") - } - options.URL = positionals[0] - if len(positionals) == 2 { - options.LocalPath = normalizeLocalPathArg(positionals[1]) + if strings.HasPrefix(positionals[0], ":") { + options.ExistingSource = strings.TrimPrefix(positionals[0], ":") + if options.ExistingSource == "" { + return usageError("add source selector requires a name") + } + if options.SourceName != "" || options.Branch != "" || options.Tag != "" || options.Revision != "" || options.PartialClone { + return usageError("add to an existing source cannot use --name, --branch, --tag, --revision, or --partial-clone") + } + if len(positionals) == 1 { + return usageError("add to an existing source requires at least one mirror") + } + } else { + options.URL = positionals[0] + } + for _, raw := range positionals[1:] { + local, upstream, found := strings.Cut(raw, "=") + local = normalizeLocalPathArg(local) + if local == "" { + return usageError("add mirror requires a local path") + } + if strings.Contains(local, "=") { + return usageError("add mirror local path cannot contain =") + } + if !found { + upstream = "" + } + for _, existing := range options.Mirrors { + if existing.LocalPath == local { + return usageError("duplicate add mirror path %s", local) + } + } + options.Mirrors = append(options.Mirrors, MirrorMapping{LocalPath: local, UpstreamPath: upstream}) } return nil } @@ -636,7 +668,7 @@ func requireArgRange(command string, args []string, min, max int) error { if len(args) < min { return usageError("%s requires %d argument(s)", command, min) } - if len(args) > max { + if max >= 0 && len(args) > max { return usageError("%s received extra argument(s)", command) } return nil @@ -651,12 +683,12 @@ func Usage() string { usage: braid [--verbose|-v | --quiet] [--no-cache | --global-cache-dir ] [options] commands: - add Add a new mirror - pull Pull one mirror or every eligible mirror - remove Remove a mirror + add Add a source or mirrors to an existing source + pull Pull one source or every eligible source + remove Remove a mirror or source diff Show local mirror changes - push Push local mirror changes upstream - sync Push local mirror changes, then pull mirrors + push Push one source's local mirror changes upstream + sync Push local mirror changes, then pull sources status Show mirror status version Show braid version completion @@ -671,21 +703,21 @@ Run "braid help" for command-specific usage. func CommandUsage(command Command) string { switch command { case CommandAdd: - return "usage: braid add [local_path] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--path|-p ] [--no-commit] [--partial-clone]\n" + return "usage: braid add [local_path[=upstream_path]...] [--name ] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--no-commit] [--partial-clone]\n" case CommandPull: - return "usage: braid pull [local_path] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--keep] [--no-commit]\n" + return "usage: braid pull [local_path|:source] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--keep] [--no-commit]\n" case CommandRemove: - return "usage: braid remove [--keep] [--no-commit]\n" + return "usage: braid remove [--keep] [--no-commit]\n" case CommandDiff: - return "usage: braid diff [local_path] [--keep] [-- ...]\n" + return "usage: braid diff [local_path|:source] [--keep] [-- ...]\n" case CommandPush: - return "usage: braid push [--branch|-b ] [--message|-m ] [--keep]\n" + return "usage: braid push [--branch|-b ] [--message|-m ] [--keep]\n" case CommandSync: - return "usage: braid sync [local_path...] [--pull-only] [--autostash] [--keep]\n" + return "usage: braid sync [local_path|:source ...] [--pull-only] [--autostash] [--keep]\n" case CommandVersion: return "usage: braid version\n" case CommandStatus: - return "usage: braid status [local_path]\n" + return "usage: braid status [local_path|:source]\n" case CommandCompletion: return "usage: braid completion bash\n" case CommandUpgradeConfig: diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index abf9101..3a84155 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -17,16 +17,15 @@ func TestParseCommands(t *testing.T) { }{ { name: "add branch mirror", - args: []string{"--verbose", "--global-cache-dir", ".cache", "add", "https://example.test/repo.git", "vendor/repo", "--branch", "main", "--path", "lib", "--no-commit"}, + args: []string{"--verbose", "--global-cache-dir", ".cache", "add", "https://example.test/repo.git", "vendor/repo=lib", "--branch", "main", "--no-commit"}, want: Invocation{ Global: GlobalOptions{GlobalCacheDir: ".cache", GlobalCacheDirSet: true, Verbose: true}, Command: CommandAdd, Add: AddOptions{ - URL: "https://example.test/repo.git", - LocalPath: "vendor/repo", - Branch: "main", - RemotePath: "lib", - NoCommit: true, + URL: "https://example.test/repo.git", + Mirrors: []MirrorMapping{{LocalPath: "vendor/repo", UpstreamPath: "lib"}}, + Branch: "main", + NoCommit: true, }, }, }, @@ -186,7 +185,7 @@ func TestParseUsageErrors(t *testing.T) { {name: "quiet verbose conflict", args: []string{"--quiet", "--verbose", "version"}, want: "--quiet and --verbose cannot be used together"}, {name: "verbose quiet conflict", args: []string{"--verbose", "--quiet", "version"}, want: "--quiet and --verbose cannot be used together"}, {name: "empty global cache dir", args: []string{"--global-cache-dir=", "version"}, want: "--global-cache-dir requires a non-empty value"}, - {name: "add extra args", args: []string{"add", "url", "path", "extra"}, want: "add received extra argument(s)"}, + {name: "existing source needs mirror", args: []string{"add", ":source"}, want: "add to an existing source requires at least one mirror"}, {name: "tag branch conflict", args: []string{"add", "url", "--tag", "v1", "--branch", "main"}, want: "add cannot combine --tag and --branch"}, {name: "pull all strategy flag", args: []string{"pull", "--branch", "main"}, want: "pull without local_path cannot use --branch, --tag, or --revision"}, {name: "diff args require separator", args: []string{"diff", "--stat"}, want: "unknown flag for diff: --stat"}, @@ -247,7 +246,10 @@ func TestParseNormalizesLocalPathArguments(t *testing.T) { func gotLocalPath(inv Invocation) string { switch inv.Command { case CommandAdd: - return inv.Add.LocalPath + if len(inv.Add.Mirrors) > 0 { + return inv.Add.Mirrors[0].LocalPath + } + return "" case CommandPull: return inv.Update.LocalPath case CommandRemove: @@ -305,13 +307,13 @@ func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) { if !strings.Contains(Usage(), "usage: braid [--verbose|-v | --quiet] [--no-cache | --global-cache-dir ] [options]") { t.Fatalf("top-level usage missing global output flag syntax:\n%s", Usage()) } - if !strings.Contains(Usage(), " pull Pull one mirror or every eligible mirror") { + if !strings.Contains(Usage(), " pull Pull one source or every eligible source") { t.Fatalf("top-level usage missing pull command:\n%s", Usage()) } if strings.Contains(Usage(), " update") || strings.Contains(Usage(), "\n up ") { t.Fatalf("top-level usage exposes update aliases:\n%s", Usage()) } - if !strings.Contains(Usage(), " sync Push local mirror changes, then pull mirrors") { + if !strings.Contains(Usage(), " sync Push local mirror changes, then pull sources") { t.Fatalf("top-level usage missing sync command:\n%s", Usage()) } if !strings.Contains(Usage(), " completion") { @@ -320,19 +322,19 @@ func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) { if strings.Contains(Usage(), "__complete") { t.Fatalf("top-level usage exposes hidden completion callback:\n%s", Usage()) } - if got, want := CommandUsage(CommandAdd), "usage: braid add [local_path] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--path|-p ] [--no-commit] [--partial-clone]\n"; got != want { + if got, want := CommandUsage(CommandAdd), "usage: braid add [local_path[=upstream_path]...] [--name ] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--no-commit] [--partial-clone]\n"; got != want { t.Fatalf("CommandUsage(add) = %q, want %q", got, want) } - if got, want := CommandUsage(CommandPull), "usage: braid pull [local_path] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--keep] [--no-commit]\n"; got != want { + if got, want := CommandUsage(CommandPull), "usage: braid pull [local_path|:source] [--branch|-b ] [--tag|-t ] [--revision|-r ] [--keep] [--no-commit]\n"; got != want { t.Fatalf("CommandUsage(pull) = %q, want %q", got, want) } - if got, want := CommandUsage(CommandRemove), "usage: braid remove [--keep] [--no-commit]\n"; got != want { + if got, want := CommandUsage(CommandRemove), "usage: braid remove [--keep] [--no-commit]\n"; got != want { t.Fatalf("CommandUsage(remove) = %q, want %q", got, want) } - if got, want := CommandUsage(CommandPush), "usage: braid push [--branch|-b ] [--message|-m ] [--keep]\n"; got != want { + if got, want := CommandUsage(CommandPush), "usage: braid push [--branch|-b ] [--message|-m ] [--keep]\n"; got != want { t.Fatalf("CommandUsage(push) = %q, want %q", got, want) } - if got, want := CommandUsage(CommandSync), "usage: braid sync [local_path...] [--pull-only] [--autostash] [--keep]\n"; got != want { + if got, want := CommandUsage(CommandSync), "usage: braid sync [local_path|:source ...] [--pull-only] [--autostash] [--keep]\n"; got != want { t.Fatalf("CommandUsage(sync) = %q, want %q", got, want) } if got, want := CommandUsage(CommandCompletion), "usage: braid completion bash\n"; got != want { @@ -398,7 +400,7 @@ func TestRunDispatchesParsedCommand(t *testing.T) { if code != 0 { t.Fatalf("run exit = %d, stderr = %q", code, stderr.String()) } - if got.Command != CommandAdd || got.Add.URL != "url" || got.Add.LocalPath != "vendor/repo" || got.Add.Branch != "main" { + if got.Command != CommandAdd || got.Add.URL != "url" || len(got.Add.Mirrors) != 1 || got.Add.Mirrors[0].LocalPath != "vendor/repo" || got.Add.Branch != "main" { t.Fatalf("dispatched invocation = %#v", got) } } diff --git a/internal/command/BUILD.bazel b/internal/command/BUILD.bazel index f92e119..156ef28 100644 --- a/internal/command/BUILD.bazel +++ b/internal/command/BUILD.bazel @@ -27,7 +27,7 @@ go_library( "//internal/cli", "//internal/config", "//internal/gitexec", - "//internal/mirror", + "//internal/source", "//internal/pathcheck", ], ) @@ -36,7 +36,7 @@ COMMAND_TEST_DEPS = [ "//internal/cli", "//internal/config", "//internal/gitexec", - "//internal/mirror", + "//internal/source", "//internal/testutil", ] @@ -151,3 +151,11 @@ go_test( timeout = "moderate", deps = COMMAND_TEST_DEPS, ) + +go_test( + name = "multi_source_test", + srcs = ["multi_source_test.go"], + embed = [":command_test_support"], + timeout = "moderate", + deps = COMMAND_TEST_DEPS, +) diff --git a/internal/command/add.go b/internal/command/add.go index 416284c..c959b98 100644 --- a/internal/command/add.go +++ b/internal/command/add.go @@ -12,8 +12,8 @@ import ( "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" "braid/internal/pathcheck" + "braid/internal/source" ) type AddHandler struct { @@ -52,48 +52,85 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c } addOptions := inv.Add - if addOptions.Revision != "" { - addOptions.Branch = "" - } - - m, err := mirror.NewFromOptions(addOptions.URL, mirror.Options{ - LocalPath: addOptions.LocalPath, - Branch: addOptions.Branch, - Tag: addOptions.Tag, - Revision: addOptions.Revision, - RemotePath: addOptions.RemotePath, - PartialClone: addOptions.PartialClone, - }) - if err != nil { - return err - } - m.Path, err = normalizeLocalPath(repo, m.Path) - if err != nil { - return err - } - if err := validateNewMirrorPath(cfg, m); err != nil { - return err + var s source.Source + addingExisting := addOptions.ExistingSource != "" + if addingExisting { + s, err = cfg.SourceByNameRequired(addOptions.ExistingSource) + if err != nil { + return err + } + } else { + name := addOptions.SourceName + if name == "" { + name = source.DerivedName(addOptions.URL) + } + if !source.ValidName(name) { + return fmt.Errorf("invalid source name %q; specify --name", name) + } + if _, exists := cfg.SourceByName(name); exists { + return fmt.Errorf("source name already exists: %s", name) + } + tracking := source.Tracking(source.RevisionTracking{}) + if addOptions.Branch != "" { + tracking = source.BranchTracking{Branch: addOptions.Branch} + } else if addOptions.Tag != "" { + tracking = source.TagTracking{Tag: addOptions.Tag} + } + s = source.Source{Name: name, URL: source.CleanURL(addOptions.URL), Tracking: tracking, Revision: addOptions.Revision, PartialClone: addOptions.PartialClone} + } + requested := addOptions.Mirrors + if len(requested) == 0 { + requested = []cli.MirrorMapping{{LocalPath: s.Name}} + } + newMirrors := make([]source.Mirror, 0, len(requested)) + existingPaths := cfg.LocalPaths() + for _, mapping := range requested { + localPath, normalizeErr := normalizeLocalPath(repo, mapping.LocalPath) + if normalizeErr != nil { + return normalizeErr + } + sm := source.Mirror{LocalPath: localPath, UpstreamPath: mapping.UpstreamPath} + candidate := s.WithMirror(sm) + if err := pathcheck.ValidateLocal(localPath, existingPaths); err != nil { + return err + } + if candidate.UpstreamPath != "" { + if err := pathcheck.ValidateUpstream(candidate.UpstreamPath); err != nil { + return err + } + } + if err := validateNewMirrorPath(cfg, candidate); err != nil { + return err + } + if mirrorOverlapsConfig(localPath) { + return fmt.Errorf("mirror path %q overlaps %s", localPath, config.FileName) + } + if err := ensureAddTargetAvailable(ctx, git, configRoot(h.Options, repo), localPath); err != nil { + return err + } + newMirrors = append(newMirrors, sm) + existingPaths = append(existingPaths, localPath) + s.Mirrors = append(s.Mirrors, sm) } - if mirrorOverlapsConfig(m.Path) { - return fmt.Errorf("mirror path %q overlaps %s", m.Path, config.FileName) + paths := make([]string, 0, len(newMirrors)) + for _, mirror := range newMirrors { + paths = append(paths, mirror.LocalPath) } - if err := ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), false, m.Path); err != nil { + if err := ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), false, paths...); err != nil { return err } - if err := ensureAddTargetAvailable(ctx, git, configRoot(h.Options, repo), m.Path); err != nil { - return err + if !addingExisting && addOptions.Branch == "" && addOptions.Tag == "" && addOptions.Revision == "" { + branch, branchErr := defaultBranch(ctx, git, s.URL, ":"+s.Name, progress) + if branchErr != nil { + return branchErr + } + s.Tracking = source.BranchTracking{Branch: branch} } - - if addOptions.Branch == "" && addOptions.Tag == "" && addOptions.Revision == "" { - branch, err := defaultBranch(ctx, git, addOptions.URL, m.Path, progress) - if err != nil { + primary := s.WithMirror(newMirrors[0]) + if !addingExisting { + if err := validateNewMirrorRemote(cfg, primary); err != nil { return err } - addOptions.Branch = branch - m.Branch = branch - } - if err := validateNewMirrorRemote(cfg, m); err != nil { - return err } cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, trace) @@ -101,15 +138,15 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c return err } if cache.Enabled { - if err := fetchCache(ctx, cache, m, inv.Global.Verbose, progress, trace); err != nil { + if err := fetchCache(ctx, cache, primary, inv.Global.Verbose, progress, trace); err != nil { return err } } - if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { + if err := configureMirrorRemote(ctx, git, primary, true, cache); err != nil { return err } - remote := m.Remote() + remote := primary.Remote() cleanupRemote := func(cause error, completed string) error { if err := git.RemoteRemove(ctx, remote); err != nil { if cause != nil { @@ -120,21 +157,26 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c return cause } - if err := fetchMirror(ctx, git, cache, m, progress); err != nil { + if err := fetchMirror(ctx, git, cache, primary, progress); err != nil { return cleanupRemote(err, "") } - revision, err := resolveAddRevision(ctx, git, m, cacheResolveRecordedRevision(cache, m, addOptions.Revision)) - if err != nil { - return cleanupRemote(err, "") + var revision string + if addingExisting { + revision, err = git.RevParse(ctx, s.Revision+"^{commit}") + } else { + revision, err = resolveAddRevision(ctx, git, primary, cacheResolveRecordedRevision(cache, primary, addOptions.Revision)) } - item, err := itemAtRevision(ctx, git, m, revision) if err != nil { return cleanupRemote(err, "") } - - m.Revision = revision - if err := cfg.Add(m); err != nil { + s.Revision = revision + if addingExisting { + err = cfg.UpdateSource(s) + } else { + err = cfg.AddSource(s) + } + if err != nil { return cleanupRemote(err, "") } configData, err := cfg.MarshalJSON() @@ -146,9 +188,16 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c return cleanupRemote(err, "") } - mirrorTree, err := git.MakeTreeWithItemIn(ctx, "HEAD", m.Path, item) - if err != nil { - return cleanupRemote(err, "") + mirrorTree := "HEAD" + for _, mirror := range newMirrors { + item, itemErr := itemAtRevision(ctx, git, s.WithMirror(mirror), revision) + if itemErr != nil { + return cleanupRemote(itemErr, "") + } + mirrorTree, err = git.MakeTreeWithItemIn(ctx, mirrorTree, mirror.LocalPath, item) + if err != nil { + return cleanupRemote(err, "") + } } finalTree, err := git.MakeTreeWithItemIn(ctx, mirrorTree, config.FileName, configItem) if err != nil { @@ -156,34 +205,43 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c } if addOptions.NoCommit { var warned bool + description := "" + if addingExisting { + description = "addition of mirrors to source ':" + s.Name + "'" + } if err := stageNoCommitResult(ctx, git, stdout, noCommitStageOptions{ - Tree: finalTree, - Action: "add", - MirrorPath: m.Path, - Paths: []string{m.Path, config.FileName}, - OwnedPaths: []string{m.Path}, - Quiet: inv.Global.Quiet, - Warned: &warned, + Tree: finalTree, + Action: "add", + MirrorPath: ":" + s.Name, + Description: description, + Paths: append(append([]string{}, paths...), config.FileName), + OwnedPaths: paths, + Quiet: inv.Global.Quiet, + Warned: &warned, }); err != nil { return cleanupRemote(err, "") } return cleanupRemote(nil, "staged changes") } - committed, err := git.CommitTreeWithTemporaryIndex(ctx, finalTree, addCommitSubject(m)) + subject := fmt.Sprintf("Braid: Add source '%s' at '%s'", s.Name, shortRevision(s.Revision)) + if addingExisting { + subject = fmt.Sprintf("Braid: Add mirrors to source '%s'", s.Name) + } + committed, err := git.CommitTreeWithTemporaryIndex(ctx, finalTree, subject) if err != nil { return cleanupRemote(err, "") } if !committed { return cleanupRemote(errors.New("add produced no commit"), "") } - if err := git.RestorePathspecsFromHead(ctx, m.Path, config.FileName); err != nil { + if err := git.RestorePathspecsFromHead(ctx, append(paths, config.FileName)...); err != nil { return cleanupRemote(err, "") } return cleanupRemote(nil, "committed") } func defaultBranch(ctx context.Context, git AddGit, url, localPath string, progress progressReporter) (branch string, err error) { - op, err := progress.Start(fmt.Sprintf("Braid: detecting default branch for mirror %s", localPath)) + op, err := progress.Start(fmt.Sprintf("Braid: detecting default branch for source %s", localPath)) if err != nil { return "", err } @@ -206,30 +264,26 @@ func defaultBranch(ctx context.Context, git AddGit, url, localPath string, progr _ = op.Abort() return "", errors.New("failed to detect default branch; specify --branch") } - if err := op.Complete(fmt.Sprintf("Braid: detected default branch for mirror %s", localPath)); err != nil { + if err := op.Complete(fmt.Sprintf("Braid: detected default branch for source %s", localPath)); err != nil { return "", err } return targets[0], nil } -func validateNewMirrorPath(cfg config.Config, candidate mirror.Mirror) error { - if err := pathcheck.ValidateLocal(candidate.Path, cfg.Paths()); err != nil { +func validateNewMirrorPath(cfg config.Config, candidate source.SourceMirror) error { + if err := pathcheck.ValidateLocal(candidate.LocalPath, cfg.LocalPaths()); err != nil { return err } - if candidate.RemotePath != "" { - if err := pathcheck.ValidateUpstream(candidate.RemotePath); err != nil { + if candidate.UpstreamPath != "" { + if err := pathcheck.ValidateUpstream(candidate.UpstreamPath); err != nil { return err } } return nil } -func validateNewMirrorRemote(cfg config.Config, candidate mirror.Mirror) error { - existing := make([]mirror.Mirror, 0, len(cfg.Mirrors)) - for _, localPath := range cfg.Paths() { - existing = append(existing, cfg.Mirrors[localPath]) - } - return pathcheck.CheckRemoteCollision(candidate, existing) +func validateNewMirrorRemote(cfg config.Config, candidate source.SourceMirror) error { + return pathcheck.CheckRemoteCollision(candidate.Source, cfg.SourcesSorted()) } func ensureAddTargetAvailable(ctx context.Context, git AddGit, root, target string) error { @@ -291,17 +345,13 @@ type revParseGit interface { RevParse(context.Context, string) (string, error) } -func resolveAddRevision(ctx context.Context, git revParseGit, m mirror.Mirror, requested string) (string, error) { +func resolveAddRevision(ctx context.Context, git revParseGit, m source.SourceMirror, requested string) (string, error) { if requested != "" { return git.RevParse(ctx, requested+"^{commit}") } return git.RevParse(ctx, m.LocalRef()+"^{commit}") } -func addCommitSubject(m mirror.Mirror) string { - return fmt.Sprintf("Braid: Add mirror '%s' at '%s'", m.Path, shortRevision(m.Revision)) -} - func shortRevision(revision string) string { if len(revision) < 7 { return revision diff --git a/internal/command/add_test.go b/internal/command/add_test.go index b4001e1..1cbd837 100644 --- a/internal/command/add_test.go +++ b/internal/command/add_test.go @@ -12,6 +12,7 @@ import ( "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" + "braid/internal/source" "braid/internal/testutil" ) @@ -26,10 +27,10 @@ func TestAddCommandDefaultBranchCommitsAndRemovesRemote(t *testing.T) { assertFile(t, repo, "vendor/basic/README.md", "hello from upstream\n") m := loadMirror(t, repo, "vendor/basic") - if m.URL != upstream || m.Branch != "main" || m.Tag != "" || m.Revision != revision { + if m.URL != upstream || m.Branch() != "main" || m.Tag() != "" || m.Revision != revision { t.Fatalf("mirror = %#v, want upstream main at %s", m, revision) } - assertCommitSubject(t, repo, "Braid: Add mirror 'vendor/basic' at '"+revision[:7]+"'") + assertCommitSubject(t, repo, "Braid: Add source '"+m.Name+"' at '"+revision[:7]+"'") assertCommitIdentity(t, repo) assertNoRemote(t, repo, m.Remote()) assertClean(t, repo) @@ -84,13 +85,13 @@ func TestAddCommandNoCommitStagesContentConfigAndPreservesUnrelatedState(t *test stdout, _ := runCommandOKWithOutput(t, repo, []string{"add", upstream, "vendor/basic", "--no-commit"}) - assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged add of mirror 'vendor/basic'") + assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged add of source ':"+source.DerivedName(upstream)+"'") if got := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } assertFile(t, repo, "vendor/basic/README.md", "hello from upstream\n") m := loadMirror(t, repo, "vendor/basic") - if m.URL != upstream || m.Branch != "main" || m.Revision != revision { + if m.URL != upstream || m.Branch() != "main" || m.Revision != revision { t.Fatalf("mirror = %#v, want staged upstream main at %s", m, revision) } cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout) @@ -109,7 +110,7 @@ func TestAddCommandNoCommitStagesContentConfigAndPreservesUnrelatedState(t *test assertNoRemote(t, repo, m.Remote()) } -func TestAddCommandNoCommitQuietSuppressesSuccess(t *testing.T) { +func TestAddCommandNoCommitQuietPreservesResult(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "quiet\n") testutil.CommitAll(t, upstream, "upstream") @@ -117,8 +118,8 @@ func TestAddCommandNoCommitQuietSuppressesSuccess(t *testing.T) { stdout, stderr := runCommandOKWithOutput(t, repo, []string{"--quiet", "add", upstream, "vendor/basic", "--no-commit"}) - if stdout != "" || stderr != "" { - t.Fatalf("stdout/stderr = %q / %q, want quiet no-commit output empty", stdout, stderr) + if stdout != "Braid: staged add of source ':001'\n" || stderr != "" { + t.Fatalf("stdout/stderr = %q / %q, want quiet staged result", stdout, stderr) } if cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout); strings.Join(cached, "\n") != ".braids.json\nvendor/basic/README.md" { t.Fatalf("cached names = %#v, want staged config and mirror", cached) @@ -135,7 +136,7 @@ func TestAddCommandNormalizesNativeLocalPathArgument(t *testing.T) { assertFile(t, repo, "vendor/native/README.md", "native path\n") m := loadMirror(t, repo, "vendor/native") - if m.Path != "vendor/native" || m.Revision != revision { + if m.LocalPath != "vendor/native" || m.Revision != revision { t.Fatalf("mirror = %#v, want normalized path at %s", m, revision) } } @@ -188,7 +189,7 @@ func TestAddCommandGlobalVerboseTracesWorktreeAndCacheGit(t *testing.T) { } trace := stderr.String() assertContains(t, trace, `Braid: Executing ["git", "--version"]`) - assertContains(t, trace, `Braid: Executing ["git", "fetch", "-n", "main_braid_vendor_basic"]`) + assertContains(t, trace, `Braid: Executing ["git", "fetch", "-n", "main_braid_`+source.DerivedName(upstream)+`"]`) assertContains(t, trace, `Braid: Executing ["git", "clone", "--mirror"`) } @@ -242,7 +243,7 @@ func TestAddCommandMirrorVariants(t *testing.T) { testutil.WriteFile(t, upstream, "ignored.txt", "ignored\n") return testutil.CommitAll(t, upstream, "subdir") }, - args: func(upstream, _ string) []string { return []string{"add", upstream, "vendor/lib", "--path", "lib"} }, + args: func(upstream, _ string) []string { return []string{"add", upstream, "vendor/lib=lib"} }, wantPath: "vendor/lib", wantFile: "vendor/lib/component.txt", wantBranch: "main", @@ -268,7 +269,7 @@ func TestAddCommandMirrorVariants(t *testing.T) { return testutil.CommitAll(t, upstream, "single file") }, args: func(upstream, _ string) []string { - return []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"} + return []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"} }, wantPath: "licenses/THIRD_PARTY.txt", wantFile: "licenses/THIRD_PARTY.txt", @@ -295,11 +296,11 @@ func TestAddCommandMirrorVariants(t *testing.T) { t.Fatalf("revision = %q, want %q", m.Revision, revision) } if test.wantLocked { - if m.Branch != "" || m.Tag != "" { + if m.Branch() != "" || m.Tag() != "" { t.Fatalf("locked mirror has branch/tag: %#v", m) } - } else if m.Branch != test.wantBranch || m.Tag != test.wantTag { - t.Fatalf("tracking = branch %q tag %q, want branch %q tag %q", m.Branch, m.Tag, test.wantBranch, test.wantTag) + } else if m.Branch() != test.wantBranch || m.Tag() != test.wantTag { + t.Fatalf("tracking = branch %q tag %q, want branch %q tag %q", m.Branch(), m.Tag(), test.wantBranch, test.wantTag) } assertClean(t, repo) }) @@ -317,7 +318,7 @@ func TestAddCommandScopedPrecheckBlocksDirtyConfig(t *testing.T) { } testutil.Git(t, repo, "add", config.FileName) testutil.Git(t, repo, "commit", "-m", "add empty braid config") - testutil.WriteFile(t, repo, config.FileName, "{\"config_version\":2,\"mirrors\":{}}\n") + testutil.WriteFile(t, repo, config.FileName, "{\"config_version\":2,\"sources\":{}}\n") stderr := runCommandError(t, repo, []string{"add", upstream, "vendor/basic"}) assertContains(t, stderr, "local changes are present in .braids.json") @@ -331,7 +332,7 @@ func TestAddCommandScopedPrecheckRunsBeforeDefaultBranchLookup(t *testing.T) { } testutil.Git(t, repo, "add", config.FileName) testutil.Git(t, repo, "commit", "-m", "add empty braid config") - testutil.WriteFile(t, repo, config.FileName, "{\"config_version\":2,\"mirrors\":{}}\n") + testutil.WriteFile(t, repo, config.FileName, "{\"config_version\":2,\"sources\":{}}\n") missingUpstream := filepath.Join(t.TempDir(), "missing-upstream") stderr := runCommandError(t, repo, []string{"add", missingUpstream, "vendor/basic"}) @@ -477,7 +478,7 @@ func TestAddCommandTags(t *testing.T) { assertFile(t, repo, localPath+"/README.md", test.name+"\n") m := loadMirror(t, repo, localPath) - if m.Tag != test.tag || m.Branch != "" || m.Revision != revision { + if m.Tag() != test.tag || m.Branch() != "" || m.Revision != revision { t.Fatalf("mirror = %#v, want tag %q at %s", m, test.tag, revision) } git := gitexec.New(repo, false, nil) @@ -511,7 +512,7 @@ func TestAddCommandMissingUpstreamPathCleansUpTemporaryRemote(t *testing.T) { repo := initDownstream(t) head := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout) - stderr := runCommandError(t, repo, []string{"add", upstream, "vendor/missing", "--path", "does-not-exist"}) + stderr := runCommandError(t, repo, []string{"add", upstream, "vendor/missing=does-not-exist"}) if !strings.Contains(stderr, "no tree item exists") { t.Fatalf("stderr = %q, want missing tree item diagnostic", stderr) } @@ -537,7 +538,7 @@ func TestAddCommandReportsPostCommitRestoreFailure(t *testing.T) { git, cli.Invocation{ Global: cli.GlobalOptions{NoCache: true}, - Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", LocalPath: "vendor/basic", Branch: "main"}, + Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", Mirrors: []cli.MirrorMapping{{LocalPath: "vendor/basic"}}, Branch: "main"}, }, progressReporter{}, new(bytes.Buffer), @@ -560,13 +561,13 @@ func TestAddCommandReportsPostCommitRemoteCleanupFailure(t *testing.T) { git, cli.Invocation{ Global: cli.GlobalOptions{NoCache: true}, - Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", LocalPath: "vendor/basic", Branch: "main"}, + Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", Mirrors: []cli.MirrorMapping{{LocalPath: "vendor/basic"}}, Branch: "main"}, }, progressReporter{}, new(bytes.Buffer), new(bytes.Buffer), ) - if err == nil || !strings.Contains(err.Error(), `add committed but failed to remove temporary remote "main_braid_vendor_basic"`) || !strings.Contains(err.Error(), "remove remote failed") { + if err == nil || !strings.Contains(err.Error(), `add committed but failed to remove temporary remote "main_braid_upstream"`) || !strings.Contains(err.Error(), "remove remote failed") { t.Fatalf("add error = %v, want post-commit remote cleanup failure", err) } if !git.committed { @@ -583,13 +584,13 @@ func TestAddCommandNoCommitReportsPostStageRemoteCleanupFailure(t *testing.T) { git, cli.Invocation{ Global: cli.GlobalOptions{NoCache: true}, - Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", LocalPath: "vendor/basic", Branch: "main", NoCommit: true}, + Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", Mirrors: []cli.MirrorMapping{{LocalPath: "vendor/basic"}}, Branch: "main", NoCommit: true}, }, progressReporter{}, new(bytes.Buffer), new(bytes.Buffer), ) - if err == nil || !strings.Contains(err.Error(), `add staged changes but failed to remove temporary remote "main_braid_vendor_basic"`) || !strings.Contains(err.Error(), "remove remote failed") { + if err == nil || !strings.Contains(err.Error(), `add staged changes but failed to remove temporary remote "main_braid_upstream"`) || !strings.Contains(err.Error(), "remove remote failed") { t.Fatalf("add error = %v, want post-stage remote cleanup failure", err) } if git.committed { @@ -606,7 +607,7 @@ func TestAddCommandNoCommitReportsStagingFailure(t *testing.T) { git, cli.Invocation{ Global: cli.GlobalOptions{NoCache: true}, - Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", LocalPath: "vendor/basic", Branch: "main", NoCommit: true}, + Add: cli.AddOptions{URL: "https://example.invalid/upstream.git", Mirrors: []cli.MirrorMapping{{LocalPath: "vendor/basic"}}, Branch: "main", NoCommit: true}, }, progressReporter{}, new(bytes.Buffer), @@ -620,6 +621,19 @@ func TestAddCommandNoCommitReportsStagingFailure(t *testing.T) { } } +func TestAddCommandRejectsNestedGitlink(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + revision := testutil.CommitAll(t, upstream, "base") + testutil.Git(t, upstream, "update-index", "--add", "--cacheinfo", "160000,"+revision+",nested/submodule") + testutil.Git(t, upstream, "commit", "-m", "add gitlink") + repo := initDownstream(t) + + stderr := runCommandError(t, repo, []string{"add", upstream, "vendor/basic"}) + assertContains(t, stderr, "contains an unsupported gitlink") + assertNoFile(t, repo, "vendor/basic") +} + type fakeAddGit struct { remoteRemoveErr error restoreErr error @@ -650,6 +664,9 @@ func (f *fakeAddGit) Fetch(context.Context, ...string) error { return nil } func (f *fakeAddGit) LsTreeItem(context.Context, string, string) (gitexec.TreeItem, error) { return gitexec.TreeItem{Mode: "100644", Type: "blob", Hash: "blob"}, nil } +func (f *fakeAddGit) TreeContainsGitlink(context.Context, string, string) (bool, error) { + return false, nil +} func (f *fakeAddGit) LsFiles(context.Context, string) (string, error) { return "", nil } diff --git a/internal/command/cache.go b/internal/command/cache.go index 29e8c43..7a5918f 100644 --- a/internal/command/cache.go +++ b/internal/command/cache.go @@ -8,12 +8,14 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strconv" "strings" "time" "braid/internal/cli" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type CacheMode string @@ -127,24 +129,21 @@ func CachePath(cacheDir, url string) string { return filepath.Join(cacheDir, hex.EncodeToString(sum[:])) } -func RepositoryCachePath(cacheDir string, m mirror.Mirror) string { - return filepath.Join(cacheDir, MirrorCacheID(m)+".git") +func RepositoryCachePath(cacheDir string, m source.SourceMirror) string { + return filepath.Join(cacheDir, objectCacheID(m.URL)+".git") } -func MirrorCacheID(m mirror.Mirror) string { - parts := []string{ - m.URL, - m.Path, - m.RemotePath, - m.TrackingName(), - m.Branch, - m.Tag, - } +func MirrorCacheID(m source.SourceMirror) string { + parts := []string{source.URLIdentity(m.URL), m.Name, m.TrackingIdentity()} sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) // The ID appears in both the cache directory and Braid refs; keep it short // enough for default Windows path limits while retaining 128 bits of hash. return hex.EncodeToString(sum[:mirrorCacheIDBytes]) } +func objectCacheID(url string) string { + sum := sha256.Sum256([]byte(source.URLIdentity(url))) + return hex.EncodeToString(sum[:mirrorCacheIDBytes]) +} func runtimeCache(global cli.GlobalOptions) (CacheConfig, error) { cwd, err := currentWorkingDir() @@ -162,7 +161,7 @@ func runtimeCacheForRepo(ctx context.Context, repo RepoContext, global cli.Globa return ResolveRepositoryCache(ctx, repo, global, os.LookupEnv, cwd, verbose, trace) } -func (cache CacheConfig) RemoteURL(m mirror.Mirror) string { +func (cache CacheConfig) RemoteURL(m source.SourceMirror) string { if !cache.Enabled { return m.URL } @@ -179,33 +178,48 @@ func (cache CacheConfig) RemoteURL(m mirror.Mirror) string { } } -func (cache CacheConfig) RecordedRef(m mirror.Mirror) string { - return "refs/braid/recorded/" + MirrorCacheID(m) +func (cache CacheConfig) RecordedRef(m source.SourceMirror) string { + return "refs/braid/recorded/" + MirrorCacheID(m) + "/" + hydrationID(m) } -func (cache CacheConfig) RequestedRef(m mirror.Mirror) string { - return "refs/braid/requested/" + MirrorCacheID(m) +func (cache CacheConfig) RequestedRef(m source.SourceMirror) string { + return "refs/braid/requested/" + MirrorCacheID(m) + "/" + hydrationID(m) } -func (cache CacheConfig) TipRef(m mirror.Mirror) string { - return "refs/braid/tip/" + MirrorCacheID(m) +func (cache CacheConfig) TipRef(m source.SourceMirror) string { + return "refs/braid/tip/" + MirrorCacheID(m) + "/" + hydrationID(m) +} +func hydrationID(m source.SourceMirror) string { + parts := []string{m.Revision, strconv.FormatBool(m.PartialClone)} + upstreamPaths := map[string]struct{}{} + for _, mirror := range m.Mirrors { + upstreamPaths[mirror.UpstreamPath] = struct{}{} + } + paths := make([]string, 0, len(upstreamPaths)) + for upstreamPath := range upstreamPaths { + paths = append(paths, upstreamPath) + } + sort.Strings(paths) + parts = append(parts, paths...) + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:8]) } -func cacheResolveRecordedRevision(cache CacheConfig, m mirror.Mirror, requested string) string { +func cacheResolveRecordedRevision(cache CacheConfig, m source.SourceMirror, requested string) string { if requested != "" && cache.Enabled && cache.Mode == CacheModeRepositoryLocal && !m.PartialClone { return cache.RecordedRef(m) } return requested } -func cacheResolveRequestedRevision(cache CacheConfig, m mirror.Mirror, requested string) string { +func cacheResolveRequestedRevision(cache CacheConfig, m source.SourceMirror, requested string) string { if requested != "" && cache.Enabled && cache.Mode == CacheModeRepositoryLocal && !m.PartialClone { return cache.RequestedRef(m) } return requested } -func (cache MirrorObjectCache) Hydrate(ctx context.Context, m mirror.Mirror, extraRevisions ...string) error { +func (cache MirrorObjectCache) Hydrate(ctx context.Context, m source.SourceMirror, extraRevisions ...string) error { if !cache.Config.Enabled { return nil } @@ -219,37 +233,37 @@ func (cache MirrorObjectCache) Hydrate(ctx context.Context, m mirror.Mirror, ext } } -func fetchCache(ctx context.Context, cache CacheConfig, m mirror.Mirror, verbose bool, progress progressReporter, trace ioWriter, extraRevisions ...string) error { +func fetchCache(ctx context.Context, cache CacheConfig, m source.SourceMirror, verbose bool, progress progressReporter, trace ioWriter, extraRevisions ...string) error { if !cache.Enabled { return nil } return runProgress( progress, - fmt.Sprintf("Braid: updating cache for mirror %s", m.Path), - fmt.Sprintf("Braid: updated cache for mirror %s", m.Path), + fmt.Sprintf("Braid: updating cache for source :%s", m.Name), + fmt.Sprintf("Braid: updated cache for source :%s", m.Name), func() error { return MirrorObjectCache{Config: cache, Verbose: verbose, Trace: trace}.Hydrate(ctx, m, extraRevisions...) }, ) } -func fetchMirror(ctx context.Context, git fetchGit, cache CacheConfig, m mirror.Mirror, progress progressReporter) error { +func fetchMirror(ctx context.Context, git fetchGit, cache CacheConfig, m source.SourceMirror, progress progressReporter) error { return runProgress( progress, - fmt.Sprintf("Braid: fetching mirror %s", m.Path), - fmt.Sprintf("Braid: fetched mirror %s", m.Path), + fmt.Sprintf("Braid: fetching source :%s", m.Name), + fmt.Sprintf("Braid: fetched source :%s", m.Name), func() error { if cache.Enabled && cache.Mode == CacheModeRepositoryLocal { - args := []string{"--update-shallow", "-n"} + args := []string{"--update-shallow", "--prune", "-n"} if m.PartialClone { args = append(args, "--filter=blob:none") } args = append(args, m.Remote()) - if m.Branch != "" { - args = append(args, "+refs/heads/"+m.Branch+":refs/remotes/"+m.Remote()+"/"+m.Branch) + if m.Branch() != "" { + args = append(args, "+refs/heads/*:refs/remotes/"+m.Remote()+"/*") } - if m.Tag != "" { - args = append(args, "+refs/tags/"+m.Tag+":"+m.LocalRef()) + if m.Tag() != "" { + args = append(args, "+refs/tags/"+m.Tag()+":"+m.LocalRef()) } if !m.PartialClone { args = append(args, "+refs/braid/*:refs/braid/*") @@ -259,15 +273,15 @@ func fetchMirror(ctx context.Context, git fetchGit, cache CacheConfig, m mirror. if err := git.Fetch(ctx, "-n", m.Remote()); err != nil { return err } - if m.Tag != "" { - return git.Fetch(ctx, "-n", m.Remote(), "+refs/tags/"+m.Tag+":"+m.LocalRef()) + if m.Tag() != "" { + return git.Fetch(ctx, "-n", m.Remote(), "+refs/tags/"+m.Tag()+":"+m.LocalRef()) } return nil }, ) } -func (cache MirrorObjectCache) hydrateGlobal(ctx context.Context, m mirror.Mirror) error { +func (cache MirrorObjectCache) hydrateGlobal(ctx context.Context, m source.SourceMirror) error { cachePath := CachePath(cache.Config.Dir, m.URL) if _, err := os.Stat(filepath.Join(cachePath, ".git")); err == nil { if err := os.RemoveAll(cachePath); err != nil { @@ -289,10 +303,10 @@ func (cache MirrorObjectCache) hydrateGlobal(ctx context.Context, m mirror.Mirro return gitexec.New(".", cache.Verbose, cache.Trace).CloneMirror(ctx, m.URL, cachePath) } -func (cache MirrorObjectCache) hydrateRepositoryLocal(ctx context.Context, m mirror.Mirror, extraRevisions ...string) error { +func (cache MirrorObjectCache) hydrateRepositoryLocal(ctx context.Context, m source.SourceMirror, extraRevisions ...string) error { cachePath := RepositoryCachePath(cache.Config.Dir, m) lockPath := cachePath + ".lock" - release, err := acquireCacheLock(ctx, lockPath, m.Path) + release, err := acquireCacheLock(ctx, lockPath, m.Name) if err != nil { return err } @@ -319,14 +333,15 @@ func (cache MirrorObjectCache) hydrateRepositoryLocal(ctx context.Context, m mir return nil } -func (cache MirrorObjectCache) hydrateRepositoryLocalReady(ctx context.Context, cachePath string, m mirror.Mirror, extraRevisions ...string) error { +func (cache MirrorObjectCache) hydrateRepositoryLocalReady(ctx context.Context, cachePath string, m source.SourceMirror, extraRevisions ...string) error { cacheGit := gitexec.New(cachePath, cache.Verbose, cache.Trace) full := false + readyRefs := map[string]string{} recordedRevision := strings.TrimSpace(m.Revision) if recordedRevision != "" { if isFullObjectID(recordedRevision) { - if err := cache.fetchFullObjectID(ctx, cacheGit, m, recordedRevision, cache.Config.RecordedRef(m)); err != nil { + if err := cache.fetchFullObjectID(ctx, cacheGit, m, recordedRevision); err != nil { full = true if err := cache.fetchFullMirror(ctx, cachePath, cacheGit, m); err != nil { return err @@ -342,9 +357,7 @@ func (cache MirrorObjectCache) hydrateRepositoryLocalReady(ctx context.Context, if err != nil { return unavailableRecordedRevisionError(m, recordedRevision) } - if err := cacheGit.UpdateRef(ctx, cache.Config.RecordedRef(m), resolved); err != nil { - return err - } + readyRefs[cache.Config.RecordedRef(m)] = resolved } for _, revision := range extraRevisions { @@ -353,7 +366,7 @@ func (cache MirrorObjectCache) hydrateRepositoryLocalReady(ctx context.Context, continue } if isFullObjectID(revision) { - if err := cache.fetchFullObjectID(ctx, cacheGit, m, revision, cache.Config.RequestedRef(m)); err != nil { + if err := cache.fetchFullObjectID(ctx, cacheGit, m, revision); err != nil { full = true if err := cache.fetchFullMirror(ctx, cachePath, cacheGit, m); err != nil { return err @@ -369,65 +382,73 @@ func (cache MirrorObjectCache) hydrateRepositoryLocalReady(ctx context.Context, if err != nil { return unavailableRecordedRevisionError(m, revision) } - if err := cacheGit.UpdateRef(ctx, cache.Config.RequestedRef(m), resolved); err != nil { - return err - } + readyRefs[cache.Config.RequestedRef(m)] = resolved } switch { - case m.Branch != "": + case m.Branch() != "": if !full { - if err := cache.fetchRepositoryLocal(ctx, cacheGit, m, "--depth=1", "+refs/heads/"+m.Branch+":refs/heads/"+m.Branch); err != nil { + if err := cache.fetchRepositoryLocal(ctx, cacheGit, m, "--depth=1", "+refs/heads/"+m.Branch()+":refs/heads/"+m.Branch()); err != nil { return err } } - resolved, err := cacheGit.RevParse(ctx, "refs/heads/"+m.Branch+"^{commit}") + resolved, err := cacheGit.RevParse(ctx, "refs/heads/"+m.Branch()+"^{commit}") if err != nil { return err } - if err := cacheGit.UpdateRef(ctx, cache.Config.TipRef(m), resolved); err != nil { + readyRefs[cache.Config.TipRef(m)] = resolved + if err := cache.materializeSourcePaths(ctx, cacheGit, m, resolved); err != nil { return err } - return cache.materializeRemotePath(ctx, cacheGit, m, resolved) - case m.Tag != "": + return cacheGit.UpdateRefsTransaction(ctx, readyRefs) + case m.Tag() != "": if !full { - if err := cache.fetchRepositoryLocal(ctx, cacheGit, m, "--depth=1", "+refs/tags/"+m.Tag+":refs/tags/"+m.Tag); err != nil { + if err := cache.fetchRepositoryLocal(ctx, cacheGit, m, "--depth=1", "+refs/tags/"+m.Tag()+":refs/tags/"+m.Tag()); err != nil { return err } } - resolved, err := cacheGit.RevParse(ctx, "refs/tags/"+m.Tag+"^{commit}") + resolved, err := cacheGit.RevParse(ctx, "refs/tags/"+m.Tag()+"^{commit}") if err != nil { return err } - if err := cacheGit.UpdateRef(ctx, cache.Config.TipRef(m), resolved); err != nil { + readyRefs[cache.Config.TipRef(m)] = resolved + if err := cache.materializeSourcePaths(ctx, cacheGit, m, resolved); err != nil { return err } - return cache.materializeRemotePath(ctx, cacheGit, m, resolved) + return cacheGit.UpdateRefsTransaction(ctx, readyRefs) } - return nil + return cacheGit.UpdateRefsTransaction(ctx, readyRefs) } -func (cache MirrorObjectCache) materializeRemotePath(ctx context.Context, git gitexec.Git, m mirror.Mirror, revision string) error { +func (cache MirrorObjectCache) materializeSourcePaths(ctx context.Context, git gitexec.Git, m source.SourceMirror, revision string) error { if !m.PartialClone { return nil } - result, err := git.RunOK(ctx, "rev-list", "--objects", revision, "--", m.RemotePath) - if err != nil { - return err - } - for _, line := range strings.Split(result.Stdout, "\n") { - fields := strings.Fields(line) - if len(fields) == 0 { - continue + seen := map[string]bool{} + for _, mirror := range m.SortedMirrors() { + args := []string{"rev-list", "--objects", revision} + if mirror.UpstreamPath != "" { + args = append(args, "--", mirror.UpstreamPath) } - if _, err := git.RunOK(ctx, "cat-file", "-e", fields[0]); err != nil { + result, err := git.RunOK(ctx, args...) + if err != nil { return err } + for _, line := range strings.Split(result.Stdout, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || seen[fields[0]] { + continue + } + seen[fields[0]] = true + if _, err := git.RunOK(ctx, "cat-file", "-e", fields[0]); err != nil { + return err + } + } } return nil } -func (cache MirrorObjectCache) ensureRepositoryLocalCache(ctx context.Context, cachePath string, m mirror.Mirror) (string, error) { +func (cache MirrorObjectCache) ensureRepositoryLocalCache(ctx context.Context, cachePath string, m source.SourceMirror) (string, error) { backupPath := "" recoveryPath := cachePath + ".backup" if _, err := os.Stat(recoveryPath); err == nil { @@ -500,7 +521,7 @@ func (cache MirrorObjectCache) ensureRepositoryLocalCache(ctx context.Context, c return backupPath, nil } -func repositoryLocalCacheMatches(ctx context.Context, git gitexec.Git, m mirror.Mirror) (bool, error) { +func repositoryLocalCacheMatches(ctx context.Context, git gitexec.Git, m source.SourceMirror) (bool, error) { mode, modeSet, err := git.ConfigGet(ctx, "--bool", "braid.partialClone") if err != nil { return false, err @@ -539,7 +560,7 @@ func repositoryLocalCacheMatches(ctx context.Context, git gitexec.Git, m mirror. return true, nil } -func (cache MirrorObjectCache) configureRepositoryLocalCache(ctx context.Context, git gitexec.Git, m mirror.Mirror) error { +func (cache MirrorObjectCache) configureRepositoryLocalCache(ctx context.Context, git gitexec.Git, m source.SourceMirror) error { if err := git.ConfigSet(ctx, "braid.cacheVersion", "1"); err != nil { return err } @@ -569,11 +590,11 @@ func isBareRepository(ctx context.Context, path string, verbose bool, trace ioWr return out == "true", nil } -func (cache MirrorObjectCache) fetchFullObjectID(ctx context.Context, git gitexec.Git, m mirror.Mirror, objectID, ref string) error { - return cache.fetchRepositoryLocal(ctx, git, m, "--depth=1", objectID+":"+ref) +func (cache MirrorObjectCache) fetchFullObjectID(ctx context.Context, git gitexec.Git, m source.SourceMirror, objectID string) error { + return cache.fetchRepositoryLocal(ctx, git, m, "--depth=1", objectID) } -func (cache MirrorObjectCache) fetchFullMirror(ctx context.Context, cachePath string, git gitexec.Git, m mirror.Mirror) error { +func (cache MirrorObjectCache) fetchFullMirror(ctx context.Context, cachePath string, git gitexec.Git, m source.SourceMirror) error { args := []string{"--prune"} if _, err := os.Stat(filepath.Join(cachePath, "shallow")); err == nil { args = append(args, "--unshallow") @@ -584,7 +605,7 @@ func (cache MirrorObjectCache) fetchFullMirror(ctx context.Context, cachePath st return cache.fetchRepositoryLocal(ctx, git, m, args...) } -func (cache MirrorObjectCache) fetchRepositoryLocal(ctx context.Context, git gitexec.Git, m mirror.Mirror, args ...string) error { +func (cache MirrorObjectCache) fetchRepositoryLocal(ctx context.Context, git gitexec.Git, m source.SourceMirror, args ...string) error { refspec := args[len(args)-1] options := args[:len(args)-1] remote := m.URL @@ -603,7 +624,7 @@ func (cache MirrorObjectCache) fetchRepositoryLocal(ctx context.Context, git git return nil } -func acquireCacheLock(ctx context.Context, lockPath, mirrorPath string) (func(), error) { +func acquireCacheLock(ctx context.Context, lockPath, sourceName string) (func(), error) { if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { return nil, err } @@ -623,14 +644,14 @@ func acquireCacheLock(ctx context.Context, lockPath, mirrorPath string) (func(), case <-ctx.Done(): return nil, ctx.Err() case <-deadline.C: - return nil, fmt.Errorf("timed out waiting for cache lock for mirror %s at %s; another Braid process may be updating the cache", mirrorPath, lockPath) + return nil, fmt.Errorf("timed out waiting for cache lock for source :%s at %s; another Braid process may be updating the cache", sourceName, lockPath) case <-ticker.C: } } } -func unavailableRecordedRevisionError(m mirror.Mirror, revision string) error { - return fmt.Errorf("recorded revision %s for mirror %s is unavailable from upstream %s; the repository-local cache may have been deleted or the upstream history may have been rewritten", revision, m.Path, m.URL) +func unavailableRecordedRevisionError(m source.SourceMirror, revision string) error { + return fmt.Errorf("recorded revision %s for source :%s is unavailable from upstream %s; the repository-local cache may have been deleted or the upstream history may have been rewritten", revision, m.Name, m.URL) } func isFullObjectID(value string) bool { diff --git a/internal/command/cache_test.go b/internal/command/cache_test.go index e3dae50..e12d311 100644 --- a/internal/command/cache_test.go +++ b/internal/command/cache_test.go @@ -10,7 +10,7 @@ import ( "braid/internal/cli" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" "braid/internal/testutil" ) @@ -163,12 +163,7 @@ func TestCachePathUsesStableWindowsSafeHashKey(t *testing.T) { } func TestMirrorCacheIDUsesStableWindowsSafeKey(t *testing.T) { - m := mirror.Mirror{ - URL: `https://example.test/repo?name=bad*chars|"<>@\windows\path`, - Path: `vendor\repo`, - RemotePath: `pkg/lib`, - Branch: "main", - } + m := testSourceMirror(`vendor\repo`, "pkg/lib", `https://example.test/repo?name=bad*chars|"<>@\windows\path`, "main", "", "", false) got := MirrorCacheID(m) gotAgain := MirrorCacheID(m) @@ -184,15 +179,40 @@ func TestMirrorCacheIDUsesStableWindowsSafeKey(t *testing.T) { } changedPath := m - changedPath.Path = "vendor/other" - if other := MirrorCacheID(changedPath); other == got { - t.Fatalf("MirrorCacheID did not change when mirror path changed") + changedPath.LocalPath = "vendor/other" + if other := MirrorCacheID(changedPath); other != got { + t.Fatalf("MirrorCacheID changed when mirror path changed") } changedRemotePath := m - changedRemotePath.RemotePath = "cmd/app" - if other := MirrorCacheID(changedRemotePath); other == got { - t.Fatalf("MirrorCacheID did not change when remote path changed") + changedRemotePath.UpstreamPath = "cmd/app" + if other := MirrorCacheID(changedRemotePath); other != got { + t.Fatalf("MirrorCacheID changed when remote path changed") + } + changedName := m + changedName.Name = "other" + if other := MirrorCacheID(changedName); other == got { + t.Fatal("MirrorCacheID did not change with source name") + } + changedKind := m + changedKind.Tracking = source.TagTracking{Tag: "main"} + if other := MirrorCacheID(changedKind); other == got { + t.Fatal("MirrorCacheID did not distinguish branch and tag tracking with the same name") + } +} + +func TestRepositoryLocalReadinessRefChangesWithMirrorTopology(t *testing.T) { + cache := CacheConfig{Enabled: true, Mode: CacheModeRepositoryLocal} + m := testSourceMirror("vendor/basic", "pkg", "https://example.test/repo.git", "main", "", strings.Repeat("a", 40), false) + before := cache.RecordedRef(m) + m.Mirrors = append(m.Mirrors, source.Mirror{LocalPath: "licenses/repo", UpstreamPath: "LICENSE"}) + afterAdd := cache.RecordedRef(m) + if afterAdd == before { + t.Fatal("recorded readiness ref did not change after adding a mirror") + } + m.Mirrors = []source.Mirror{{LocalPath: "vendor/basic", UpstreamPath: ""}} + if afterRoot := cache.RecordedRef(m); afterRoot == before || afterRoot == afterAdd { + t.Fatal("recorded readiness ref did not change after promoting hydration to the root") } } @@ -203,7 +223,7 @@ func TestRepositoryLocalHydrateBranchPinsRefs(t *testing.T) { revision := testutil.CommitAll(t, upstream, "base") repo := testutil.InitRepo(t) cache := repositoryLocalCacheForTest(t, ctx, repo) - m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision} + m := testSourceMirror("vendor/basic", "", upstream, "main", "", revision, false) if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { t.Fatalf("Hydrate returned error: %v", err) @@ -224,7 +244,7 @@ func TestRepositoryLocalHydrateShortRevisionUsesFullFallback(t *testing.T) { revision := testutil.CommitAll(t, upstream, "base") repo := testutil.InitRepo(t) cache := repositoryLocalCacheForTest(t, ctx, repo) - m := mirror.Mirror{Path: "vendor/revision", URL: upstream, Revision: revision[:12]} + m := testSourceMirror("vendor/revision", "", upstream, "", "", revision[:12], false) if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { t.Fatalf("Hydrate returned error: %v", err) @@ -247,7 +267,7 @@ func TestRepositoryLocalHydrateReplacesFileCachePath(t *testing.T) { revision := testutil.CommitAll(t, upstream, "base") repo := testutil.InitRepo(t) cache := repositoryLocalCacheForTest(t, ctx, repo) - m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision} + m := testSourceMirror("vendor/basic", "", upstream, "main", "", revision, false) cachePath := RepositoryCachePath(cache.Dir, m) if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { t.Fatalf("create cache parent: %v", err) @@ -274,7 +294,7 @@ func TestFetchMirrorFromRepositoryLocalCacheFetchesBraidRefs(t *testing.T) { revision := testutil.CommitAll(t, upstream, "base") repo := testutil.InitRepo(t) cache := repositoryLocalCacheForTest(t, ctx, repo) - m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision} + m := testSourceMirror("vendor/basic", "", upstream, "main", "", revision, false) if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { t.Fatalf("Hydrate returned error: %v", err) @@ -304,7 +324,7 @@ func TestFetchTagMirrorFromRepositoryLocalCacheUsesRemoteTrackingRef(t *testing. testutil.Git(t, upstream, "tag", "v1") repo := testutil.InitRepo(t) cache := repositoryLocalCacheForTest(t, ctx, repo) - m := mirror.Mirror{Path: "vendor/basic", URL: upstream, Tag: "v1", Revision: revision} + m := testSourceMirror("vendor/basic", "", upstream, "", "v1", revision, false) if err := (MirrorObjectCache{Config: cache}).Hydrate(ctx, m); err != nil { t.Fatalf("Hydrate returned error: %v", err) @@ -322,7 +342,7 @@ func TestFetchTagMirrorFromRepositoryLocalCacheUsesRemoteTrackingRef(t *testing. } func TestAcquireCacheLockTimesOut(t *testing.T) { - lockPath := filepath.Join(t.TempDir(), "mirror.git.lock") + lockPath := filepath.Join(t.TempDir(), "source.git.lock") if err := os.Mkdir(lockPath, 0o700); err != nil { t.Fatalf("create lock: %v", err) } @@ -336,7 +356,7 @@ func TestAcquireCacheLockTimesOut(t *testing.T) { }) _, err := acquireCacheLock(context.Background(), lockPath, "vendor/basic") - if err == nil || !strings.Contains(err.Error(), "timed out waiting for cache lock for mirror vendor/basic") { + if err == nil || !strings.Contains(err.Error(), "timed out waiting for cache lock for source :vendor/basic") { t.Fatalf("acquireCacheLock error = %v, want timeout diagnostic", err) } } diff --git a/internal/command/completion.go b/internal/command/completion.go index f7dca58..f9dca43 100644 --- a/internal/command/completion.go +++ b/internal/command/completion.go @@ -68,10 +68,10 @@ var globalCompletionFlags = []completionFlag{ var commandCompletionFlags = map[string][]completionFlag{ string(cli.CommandAdd): { + {long: "--name", value: true}, {long: "--branch", short: "-b", value: true}, {long: "--tag", short: "-t", value: true}, {long: "--revision", short: "-r", value: true}, - {long: "--path", short: "-p", value: true}, {long: "--no-commit"}, {long: "--partial-clone"}, }, @@ -193,9 +193,13 @@ func (h CompleteHandler) completeCommand(ctx context.Context, line completionLin } state := parseCompletedCommandArgs(command, line.commandArgs, flags) + optionFlags := flags + if command == string(cli.CommandAdd) && len(state.positionals) > 0 && strings.HasPrefix(state.positionals[0], ":") { + optionFlags = []completionFlag{{long: "--no-commit"}} + } var candidates []string if line.current == "" || strings.HasPrefix(line.current, "-") { - candidates = append(candidates, optionCandidates(line.current, flags, line.commandArgs, false)...) + candidates = append(candidates, optionCandidates(line.current, optionFlags, line.commandArgs, false)...) } if line.current == "" || !strings.HasPrefix(line.current, "-") { candidates = append(candidates, h.pathCandidatesForCommand(ctx, command, line, state)...) @@ -223,7 +227,7 @@ func completeCompletionCommand(line completionLine) []string { func (h CompleteHandler) pathCandidatesForCommand(ctx context.Context, command string, line completionLine, state commandArgState) []string { switch command { case string(cli.CommandAdd): - if len(state.positionals) == 1 { + if len(state.positionals) >= 1 { return pathCandidates(h.Options.WorkDir, line.current, false, "") } case string(cli.CommandPull), string(cli.CommandRemove), string(cli.CommandDiff), string(cli.CommandPush), string(cli.CommandStatus): @@ -385,7 +389,12 @@ func (h CompleteHandler) mirrorPathCandidates(ctx context.Context, current strin } selectedPaths := map[string]bool{} + selectedSources := map[string]bool{} for _, value := range selected { + if selection, selectionErr := resolveSourceSelection(repo, cfg, value, false); selectionErr == nil { + selectedSources[selection.Source.Name] = true + continue + } localPath, err := normalizeLocalPath(repo, value) if err == nil { selectedPaths[localPath] = true @@ -393,7 +402,17 @@ func (h CompleteHandler) mirrorPathCandidates(ctx context.Context, current strin } var candidates []string - for _, localPath := range cfg.Paths() { + for _, name := range cfg.SourceNames() { + candidate := ":" + name + if !selectedSources[name] && strings.HasPrefix(candidate, current) { + candidates = append(candidates, candidate) + } + } + for _, item := range cfg.MirrorsSorted() { + localPath := item.LocalPath + if selectedSources[item.Name] { + continue + } if selectedPaths[localPath] { continue } diff --git a/internal/command/completion_test.go b/internal/command/completion_test.go index 91e2444..d332a20 100644 --- a/internal/command/completion_test.go +++ b/internal/command/completion_test.go @@ -76,6 +76,13 @@ func TestCompleteCommandOptions(t *testing.T) { candidates = completeCandidates(t, dir, "add", "") assertCandidate(t, candidates, "--no-commit") + candidates = completeCandidates(t, dir, "add", ":replicant", "--") + assertCandidate(t, candidates, "--no-commit") + assertNoCandidate(t, candidates, "--name") + assertNoCandidate(t, candidates, "--branch") + assertNoCandidate(t, candidates, "--tag") + assertNoCandidate(t, candidates, "--revision") + assertNoCandidate(t, candidates, "--partial-clone") candidates = completeCandidates(t, dir, "push", "") assertCandidate(t, candidates, "--message") @@ -109,6 +116,7 @@ func TestCompleteMirrorPathsRelativeToCurrentDirectory(t *testing.T) { } candidates := completeCandidates(t, subdir, "status", "") + assertCandidate(t, candidates, ":root") assertCandidate(t, candidates, "vendor/local") assertCandidate(t, candidates, "../../vendor/root") assertCandidate(t, candidates, "../../path with spaces/lib") @@ -227,21 +235,24 @@ func writeCompletionConfig(t *testing.T, repo string) { t.Helper() data := []byte(`{ "config_version": 2, - "mirrors": { - "apps/web/vendor/local": { + "sources": { + "local": { "url": "https://example.test/local.git", "branch": "main", - "revision": "1111111" + "revision": "1111111", + "mirrors": {"apps/web/vendor/local": ""} }, - "path with spaces/lib": { + "spaces": { "url": "https://example.test/spaces.git", "branch": "main", - "revision": "2222222" + "revision": "2222222", + "mirrors": {"path with spaces/lib": ""} }, - "vendor/root": { + "root": { "url": "https://example.test/root.git", "branch": "main", - "revision": "3333333" + "revision": "3333333", + "mirrors": {"vendor/root": ""} } } } diff --git a/internal/command/diff.go b/internal/command/diff.go index cdd8ea4..eadb951 100644 --- a/internal/command/diff.go +++ b/internal/command/diff.go @@ -9,7 +9,7 @@ import ( "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type DiffHandler struct { @@ -39,22 +39,29 @@ func (h DiffHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { } if inv.Diff.LocalPath != "" { - localPath, err := normalizeLocalPath(repo, inv.Diff.LocalPath) + selection, err := resolveSourceSelection(repo, cfg, inv.Diff.LocalPath, true) if err != nil { return err } - m, err := cfg.GetRequired(localPath) - if err != nil { - return err + for _, mirror := range selection.Mirrors { + if len(selection.Mirrors) > 1 { + if _, err := fmt.Fprintf(stdout, "=======================================================\nBraid: Diffing mirror %s\n=======================================================\n", mirror.LocalPath); err != nil { + return err + } + } + if err := h.diffOne(ctx, git, processGit, cache, selection.Source.WithMirror(mirror), inv.Diff, inv.Global.Verbose, progress, stdout, stderr); err != nil { + return err + } } - return h.diffOne(ctx, git, processGit, cache, m, inv.Diff, inv.Global.Verbose, progress, stdout, stderr) + return nil } - for _, localPath := range cfg.Paths() { - if _, err := fmt.Fprintf(stdout, "=======================================================\nBraid: Diffing %s\n=======================================================\n", localPath); err != nil { + for _, m := range cfg.MirrorsSorted() { + localPath := m.LocalPath + if _, err := fmt.Fprintf(stdout, "=======================================================\nBraid: Diffing mirror %s\n=======================================================\n", localPath); err != nil { return err } - if err := h.diffOne(ctx, git, processGit, cache, cfg.Mirrors[localPath], inv.Diff, inv.Global.Verbose, progress, stdout, stderr); err != nil { + if err := h.diffOne(ctx, git, processGit, cache, m, inv.Diff, inv.Global.Verbose, progress, stdout, stderr); err != nil { return err } } @@ -81,7 +88,7 @@ func (h DiffHandler) processDiffGit(repo RepoContext, inv cli.Invocation, trace return gitexec.New(repo.ProcessWorkDir, inv.Global.Verbose, trace) } -func (h DiffHandler) diffOne(ctx context.Context, git, processGit DiffGit, cache CacheConfig, m mirror.Mirror, options cli.DiffOptions, verbose bool, progress progressReporter, stdout, trace io.Writer) (err error) { +func (h DiffHandler) diffOne(ctx context.Context, git, processGit DiffGit, cache CacheConfig, m source.SourceMirror, options cli.DiffOptions, verbose bool, progress progressReporter, stdout, trace io.Writer) (err error) { if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return err } @@ -109,7 +116,7 @@ func (h DiffHandler) diffOne(ctx context.Context, git, processGit DiffGit, cache return err } -func fetchBaseRevisionIfMissing(ctx context.Context, git DiffGit, cache CacheConfig, m mirror.Mirror, verbose bool, progress progressReporter, trace io.Writer) error { +func fetchBaseRevisionIfMissing(ctx context.Context, git DiffGit, cache CacheConfig, m source.SourceMirror, verbose bool, progress progressReporter, trace io.Writer) error { if _, err := git.RevParse(ctx, m.Revision+"^{commit}"); err == nil { return nil } @@ -121,34 +128,34 @@ func fetchBaseRevisionIfMissing(ctx context.Context, git DiffGit, cache CacheCon return fetchMirror(ctx, git, cache, m, progress) } -func buildDiffArgs(ctx context.Context, git DiffGit, m mirror.Mirror, userArgs []string) ([]string, error) { +func buildDiffArgs(ctx context.Context, git DiffGit, m source.SourceMirror, userArgs []string) ([]string, error) { item, err := baseDiffItem(ctx, git, m) if err != nil { return nil, err } - baseTree, err := git.MakeTreeWithItem(ctx, m.Path, item) + baseTree, err := git.MakeTreeWithItem(ctx, m.LocalPath, item) if err != nil { return nil, err } if item.Type == "blob" { args := []string{ - "--relative=" + m.Path, - "--src-prefix=a/" + path.Base(m.RemotePath), - "--dst-prefix=b/" + path.Base(m.Path), + "--relative=" + m.LocalPath, + "--src-prefix=a/" + path.Base(m.UpstreamPath), + "--dst-prefix=b/" + path.Base(m.LocalPath), baseTree, } args = append(args, userArgs...) - return append(args, ":(top)"+m.Path), nil + return append(args, ":(top)"+m.LocalPath), nil } - args := []string{"--relative=" + m.Path + "/", baseTree} + args := []string{"--relative=" + m.LocalPath + "/", baseTree} return append(args, userArgs...), nil } -func baseDiffItem(ctx context.Context, git DiffGit, m mirror.Mirror) (gitexec.TreeItem, error) { - if m.RemotePath == "" { +func baseDiffItem(ctx context.Context, git DiffGit, m source.SourceMirror) (gitexec.TreeItem, error) { + if m.UpstreamPath == "" { return gitexec.TreeItem{Type: "tree", Hash: m.Revision}, nil } - return git.LsTreeItem(ctx, m.Revision, m.RemotePath) + return git.LsTreeItem(ctx, m.Revision, m.UpstreamPath) } diff --git a/internal/command/diff_test.go b/internal/command/diff_test.go index d67ef3e..1d0341b 100644 --- a/internal/command/diff_test.go +++ b/internal/command/diff_test.go @@ -78,8 +78,8 @@ func TestDiffCommandAllMirrors(t *testing.T) { testutil.WriteFile(t, repo, "vendor/two/README.md", "two changed\n") out := runCommandOK(t, repo, []string{"diff"}) - assertContains(t, out, "Braid: Diffing vendor/one") - assertContains(t, out, "Braid: Diffing vendor/two") + assertContains(t, out, "Braid: Diffing mirror vendor/one") + assertContains(t, out, "Braid: Diffing mirror vendor/two") assertContains(t, out, "one changed") assertContains(t, out, "two changed") } @@ -128,7 +128,7 @@ func TestDiffCommandMirrorVariants(t *testing.T) { testutil.WriteFile(t, upstream, "lib/component.txt", "subdir base\n") return testutil.CommitAll(t, upstream, "subdir base") }, - addArgs: func(upstream, _ string) []string { return []string{"add", upstream, "vendor/lib", "--path", "lib"} }, + addArgs: func(upstream, _ string) []string { return []string{"add", upstream, "vendor/lib=lib"} }, localPath: "vendor/lib", localFile: "vendor/lib/component.txt", wantPath: "component.txt", @@ -168,7 +168,7 @@ func TestDiffCommandSingleFilePrefixes(t *testing.T) { testutil.CommitAll(t, upstream, "single file") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"}) + runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"}) testutil.WriteFile(t, repo, "licenses/THIRD_PARTY.txt", "changed license\n") out := runCommandOK(t, repo, []string{"diff", "licenses/THIRD_PARTY.txt"}) @@ -182,7 +182,7 @@ func TestDiffCommandSingleFileFromSubdirectoryUsesTopAnchoredLimiter(t *testing. testutil.CommitAll(t, upstream, "single file") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"}) + runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"}) testutil.WriteFile(t, repo, "licenses/THIRD_PARTY.txt", "changed license\n") workDir := filepath.Join(repo, "apps", "web") if err := os.MkdirAll(workDir, 0o755); err != nil { diff --git a/internal/command/multi_source_test.go b/internal/command/multi_source_test.go new file mode 100644 index 0000000..bea1634 --- /dev/null +++ b/internal/command/multi_source_test.go @@ -0,0 +1,231 @@ +package command + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "braid/internal/config" + "braid/internal/gitexec" + "braid/internal/testutil" +) + +func TestMultiMirrorSourceLifecycle(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "LICENSE", "one\n") + testutil.WriteFile(t, upstream, "src/code.txt", "one\n") + first := testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "vendor/replicant", "licenses/replicant-LICENSE=LICENSE"}) + assertFile(t, repo, "vendor/replicant/LICENSE", "one\n") + assertFile(t, repo, "licenses/replicant-LICENSE", "one\n") + cfg, err := config.Load(repo) + if err != nil { + t.Fatal(err) + } + s, ok := cfg.SourceByName("replicant") + if !ok || s.Revision != first || len(s.Mirrors) != 2 { + t.Fatalf("source=%#v", s) + } + statusOut, statusProgress := runCommandOKWithOutput(t, repo, []string{"status", ":replicant"}) + assertContains(t, statusOut, "licenses/replicant-LICENSE") + assertContains(t, statusOut, "vendor/replicant") + if got := strings.Count(statusProgress, "Braid: fetched source :replicant"); got != 1 { + t.Fatalf("status fetched source %d times, want once:\n%s", got, statusProgress) + } + testutil.WriteFile(t, repo, "licenses/replicant-LICENSE", "local license\n") + testutil.WriteFile(t, repo, "vendor/replicant/src/code.txt", "local code\n") + pathDiff := runCommandOK(t, repo, []string{"diff", "licenses/replicant-LICENSE"}) + assertContains(t, pathDiff, "local license") + assertNotContains(t, pathDiff, "local code") + sourceDiff := runCommandOK(t, repo, []string{"diff", ":replicant"}) + assertContains(t, sourceDiff, "local license") + assertContains(t, sourceDiff, "local code") + testutil.Git(t, repo, "restore", "licenses/replicant-LICENSE", "vendor/replicant/src/code.txt") + testutil.WriteFile(t, upstream, "LICENSE", "two\n") + testutil.WriteFile(t, upstream, "src/code.txt", "two\n") + testutil.CommitAll(t, upstream, "update") + runCommandOK(t, repo, []string{"pull", ":replicant"}) + assertFile(t, repo, "vendor/replicant/LICENSE", "two\n") + assertFile(t, repo, "licenses/replicant-LICENSE", "two\n") + runCommandOK(t, repo, []string{"remove", "licenses/replicant-LICENSE"}) + cfg, err = config.Load(repo) + if err != nil { + t.Fatal(err) + } + s, _ = cfg.SourceByName("replicant") + if len(s.Mirrors) != 1 { + t.Fatalf("mirrors=%#v", s.Mirrors) + } + if _, err := os.Stat(filepath.Join(repo, "licenses/replicant-LICENSE")); !os.IsNotExist(err) { + t.Fatalf("removed mirror still exists: %v", err) + } + runCommandOK(t, repo, []string{"remove", "vendor/replicant"}) + cfg, err = config.Load(repo) + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.SourceByName("replicant"); ok { + t.Fatal("removing the final mirror retained the source") + } +} + +func TestAggregatePullRollsBackConflictApplyFailure(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "vendor/replicant"}) + testutil.WriteFile(t, repo, "vendor/replicant/README.md", "local\n") + testutil.CommitAll(t, repo, "local") + configBefore := readTestFile(t, filepath.Join(repo, config.FileName)) + testutil.WriteFile(t, upstream, "README.md", "remote\n") + testutil.CommitAll(t, upstream, "remote") + git := failConflictAddGit{Git: gitexec.New(repo, false, nil)} + + stderr := runCommandErrorInDirWithOptions(t, repo, repo, Options{Git: git}, []string{"pull", ":replicant"}) + assertContains(t, stderr, "injected conflict add failure") + assertFile(t, repo, "vendor/replicant/README.md", "local\n") + if got := readTestFile(t, filepath.Join(repo, config.FileName)); got != configBefore { + t.Fatalf("config changed after rollback:\n%s", got) + } + if unmerged := strings.TrimSpace(testutil.Git(t, repo, "ls-files", "-u").Stdout); unmerged != "" { + t.Fatalf("unmerged entries after rollback: %q", unmerged) + } + assertNoFile(t, repo, ".git/MERGE_MSG") +} + +func TestAggregatePullMaterializesRenameConflictStages(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "old.txt", "content\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "vendor/replicant"}) + testutil.Git(t, repo, "mv", "vendor/replicant/old.txt", "vendor/replicant/local.txt") + testutil.Git(t, repo, "commit", "-m", "local rename") + testutil.Git(t, upstream, "mv", "old.txt", "remote.txt") + testutil.Git(t, upstream, "commit", "-m", "remote rename") + + runCommandOK(t, repo, []string{"pull", ":replicant"}) + unmerged := testutil.Git(t, repo, "ls-files", "-u").Stdout + assertContains(t, unmerged, " 1\tvendor/replicant/old.txt") + assertContains(t, unmerged, " 2\tvendor/replicant/local.txt") + assertContains(t, unmerged, " 3\tvendor/replicant/remote.txt") +} + +func TestPullFailureRestoresPreexistingSourceRemote(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "vendor/replicant"}) + m := loadMirror(t, repo, "vendor/replicant") + oldURL := filepath.Join(t.TempDir(), "retained-remote") + testutil.Git(t, repo, "remote", "add", m.Remote(), oldURL) + oldFetch := "+refs/heads/custom:refs/remotes/" + m.Remote() + "/custom" + testutil.Git(t, repo, "config", "--replace-all", "remote."+m.Remote()+".fetch", oldFetch) + cfg, err := config.Load(repo) + if err != nil { + t.Fatal(err) + } + s, _ := cfg.SourceByName("replicant") + s.URL = filepath.Join(t.TempDir(), "missing-upstream") + if err := cfg.UpdateSource(s); err != nil { + t.Fatal(err) + } + if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { + t.Fatal(err) + } + testutil.Git(t, repo, "add", config.FileName) + testutil.Git(t, repo, "commit", "-m", "break source URL") + + runCommandError(t, repo, []string{"--no-cache", "pull", ":replicant"}) + if got := strings.TrimSpace(testutil.Git(t, repo, "remote", "get-url", m.Remote()).Stdout); got != oldURL { + t.Fatalf("remote URL = %q, want restored %q", got, oldURL) + } + if got := strings.TrimSpace(testutil.Git(t, repo, "config", "--get-all", "remote."+m.Remote()+".fetch").Stdout); got != oldFetch { + t.Fatalf("remote fetch = %q, want restored %q", got, oldFetch) + } +} + +type failConflictAddGit struct{ gitexec.Git } + +func (failConflictAddGit) Add(context.Context, string) error { + return errors.New("injected conflict add failure") +} + +func TestPushRejectsDivergentOverlappingMirrors(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "LICENSE", "one\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "vendor/replicant", "licenses/replicant-LICENSE=LICENSE"}) + testutil.WriteFile(t, repo, "vendor/replicant/LICENSE", "different\n") + testutil.CommitAll(t, repo, "diverge copies") + err := runCommandError(t, repo, []string{"push", ":replicant", "--message", "push"}) + if !strings.Contains(err, "inconsistent upstream content") { + t.Fatalf("error=%q", err) + } + if got := readTestFile(t, filepath.Join(upstream, "LICENSE")); got != "one\n" { + t.Fatalf("upstream changed: %q", got) + } +} + +func TestAddMirrorToExistingSourceUsesRecordedRevision(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "LICENSE", "recorded\n") + testutil.WriteFile(t, upstream, "src/code", "recorded\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "licenses/LICENSE=LICENSE"}) + testutil.WriteFile(t, upstream, "src/code", "latest\n") + testutil.CommitAll(t, upstream, "latest") + runCommandOK(t, repo, []string{"add", ":replicant", "vendor/src=src"}) + assertFile(t, repo, "vendor/src/code", "recorded\n") +} + +func TestPushConsistentOverlappingMirrors(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.Git(t, upstream, "config", "receive.denyCurrentBranch", "updateInstead") + testutil.WriteFile(t, upstream, "LICENSE", "one\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "replicant", "vendor/replicant", "licenses/LICENSE=LICENSE"}) + testutil.WriteFile(t, repo, "vendor/replicant/LICENSE", "two\n") + testutil.WriteFile(t, repo, "licenses/LICENSE", "two\n") + testutil.CommitAll(t, repo, "update both") + runCommandOK(t, repo, []string{"push", ":replicant", "--message", "update license"}) + if got := readTestFile(t, filepath.Join(upstream, "LICENSE")); got != "two\n" { + t.Fatalf("upstream=%q", got) + } +} + +func TestAggregatePullMaterializesConflictStages(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "a.txt", "base\n") + testutil.WriteFile(t, upstream, "b.txt", "base\n") + testutil.WriteFile(t, upstream, "c.txt", "one\nmiddle\nthree\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "--name", "pair", "vendor/a=a.txt", "vendor/b=b.txt", "vendor/c=c.txt"}) + testutil.WriteFile(t, repo, "vendor/a", "local\n") + testutil.WriteFile(t, repo, "vendor/c", "local\nmiddle\nthree\n") + testutil.CommitAll(t, repo, "local") + testutil.WriteFile(t, upstream, "a.txt", "remote\n") + testutil.WriteFile(t, upstream, "b.txt", "remote\n") + testutil.WriteFile(t, upstream, "c.txt", "one\nmiddle\nremote\n") + testutil.CommitAll(t, upstream, "remote") + runCommandOK(t, repo, []string{"pull", ":pair"}) + unmerged := testutil.Git(t, repo, "ls-files", "-u").Stdout + if !strings.Contains(unmerged, "vendor/a") { + t.Fatalf("unmerged=%q", unmerged) + } + if strings.Contains(unmerged, "vendor/c") { + t.Fatalf("cleanly merged file was left unmerged: %q", unmerged) + } + assertFile(t, repo, "vendor/b", "remote\n") + assertFile(t, repo, "vendor/c", "local\nmiddle\nremote\n") +} diff --git a/internal/command/no_commit.go b/internal/command/no_commit.go index b2c9fb1..2b4bed3 100644 --- a/internal/command/no_commit.go +++ b/internal/command/no_commit.go @@ -17,13 +17,14 @@ type noCommitGit interface { } type noCommitStageOptions struct { - Tree string - Action string - MirrorPath string - Paths []string - OwnedPaths []string - Quiet bool - Warned *bool + Tree string + Action string + MirrorPath string + Description string + Paths []string + OwnedPaths []string + Quiet bool + Warned *bool } func stageNoCommitResult(ctx context.Context, git noCommitGit, stdout io.Writer, options noCommitStageOptions) error { @@ -44,10 +45,15 @@ func stageNoCommitResult(ctx context.Context, git noCommitGit, stdout io.Writer, if err := git.RestorePathspecsFromTree(ctx, options.Tree, true, true, options.Paths...); err != nil { return err } - if options.Quiet { - return nil + if options.Description != "" { + _, err := fmt.Fprintf(stdout, "Braid: staged %s\n", options.Description) + return err + } + target := "mirror '" + options.MirrorPath + "'" + if strings.HasPrefix(options.MirrorPath, ":") { + target = "source '" + options.MirrorPath + "'" } - _, err := fmt.Fprintf(stdout, "Braid: staged %s of mirror '%s'\n", options.Action, options.MirrorPath) + _, err := fmt.Fprintf(stdout, "Braid: staged %s of %s\n", options.Action, target) return err } diff --git a/internal/command/path.go b/internal/command/path.go index 650aefd..009cf0b 100644 --- a/internal/command/path.go +++ b/internal/command/path.go @@ -6,8 +6,33 @@ import ( "path" "path/filepath" "strings" + + "braid/internal/config" + "braid/internal/source" ) +func resolveSourceSelection(repo RepoContext, cfg config.Config, raw string, pathScoped bool) (source.SourceSelection, error) { + if strings.HasPrefix(raw, ":") { + s, err := cfg.SourceByNameRequired(strings.TrimPrefix(raw, ":")) + if err != nil { + return source.SourceSelection{}, err + } + return source.SourceSelection{Source: s, Mirrors: s.SortedMirrors()}, nil + } + localPath, err := normalizeLocalPath(repo, raw) + if err != nil { + return source.SourceSelection{}, err + } + s, m, err := cfg.MirrorByLocalPathRequired(localPath) + if err != nil { + return source.SourceSelection{}, err + } + if pathScoped { + return source.SourceSelection{Source: s, Mirrors: []source.Mirror{m}}, nil + } + return source.SourceSelection{Source: s, Mirrors: s.SortedMirrors()}, nil +} + func normalizeLocalPath(repo RepoContext, value string) (string, error) { value = strings.ReplaceAll(value, `\`, "/") if value == "" { diff --git a/internal/command/preflight.go b/internal/command/preflight.go index 8b93d66..0025cfa 100644 --- a/internal/command/preflight.go +++ b/internal/command/preflight.go @@ -34,6 +34,7 @@ type AddGit interface { LsRemote(context.Context, ...string) (string, error) Fetch(context.Context, ...string) error LsTreeItem(context.Context, string, string) (gitexec.TreeItem, error) + TreeContainsGitlink(context.Context, string, string) (bool, error) LsFiles(context.Context, string) (string, error) StatusPorcelainPathspecs(context.Context, ...string) (string, error) BlockingOperation(context.Context) (string, bool, error) @@ -50,6 +51,7 @@ type DiffGit interface { RevParse(context.Context, string) (string, error) Fetch(context.Context, ...string) error LsTreeItem(context.Context, string, string) (gitexec.TreeItem, error) + TreeContainsGitlink(context.Context, string, string) (bool, error) MakeTreeWithItem(context.Context, string, gitexec.TreeItem) (string, error) Diff(context.Context, ...string) (string, error) } diff --git a/internal/command/preflight_test.go b/internal/command/preflight_test.go index c821ba4..7e10d25 100644 --- a/internal/command/preflight_test.go +++ b/internal/command/preflight_test.go @@ -115,7 +115,7 @@ func TestConfigRequirements(t *testing.T) { func configRootWithModernConfig(t *testing.T) string { t.Helper() root := t.TempDir() - data := []byte(`{"config_version":2,"mirrors":{"vendor/repo":{"url":"u","branch":"main","revision":"r"}}}`) + data := []byte(`{"config_version":2,"sources":{"repo":{"url":"u","branch":"main","revision":"r","mirrors":{"vendor/repo":""}}}}`) if err := os.WriteFile(filepath.Join(root, ".braids.json"), data, 0o644); err != nil { t.Fatalf("write config: %v", err) } diff --git a/internal/command/progress_integration_test.go b/internal/command/progress_integration_test.go index 3f031df..cdb28f7 100644 --- a/internal/command/progress_integration_test.go +++ b/internal/command/progress_integration_test.go @@ -21,16 +21,16 @@ func TestProgressAddReportsRemoteOperationsAndQuietSuppresses(t *testing.T) { assertEmptyOutput(t, "add stdout", stdout) assertInOrder(t, stderr, - "Braid: detecting default branch for mirror vendor/basic", - "Braid: detected default branch for mirror vendor/basic", - "Braid: updating cache for mirror vendor/basic", - "Braid: updated cache for mirror vendor/basic", - "Braid: fetching mirror vendor/basic", - "Braid: fetched mirror vendor/basic", + "Braid: detecting default branch for source :001", + "Braid: detected default branch for source :001", + "Braid: updating cache for source :001", + "Braid: updated cache for source :001", + "Braid: fetching source :001", + "Braid: fetched source :001", ) assertNotContains(t, stderr, upstream) - stdout, stderr = runCommandOKWithOutput(t, repo, []string{"--quiet", "add", upstream, "vendor/quiet"}) + stdout, stderr = runCommandOKWithOutput(t, repo, []string{"--quiet", "add", ":001", "vendor/quiet"}) assertEmptyOutput(t, "quiet add stdout", stdout) assertNoProgressOutput(t, stderr) } @@ -44,28 +44,28 @@ func TestProgressPullReportsNoopUpdateAndQuietSuppresses(t *testing.T) { runCommandOK(t, repo, []string{"--quiet", "add", upstream, "vendor/basic"}) stdout, stderr := runCommandOKWithOutput(t, repo, []string{"pull", "vendor/basic"}) - assertEmptyOutput(t, "noop pull stdout", stdout) + assertContains(t, stdout, "Braid: source :001 is already up to date") assertInOrder(t, stderr, - "Braid: updating cache for mirror vendor/basic", - "Braid: updated cache for mirror vendor/basic", - "Braid: fetching mirror vendor/basic", - "Braid: fetched mirror vendor/basic", - "Braid: checking mirror vendor/basic", - "Braid: mirror vendor/basic already up to date", + "Braid: updating cache for source :001", + "Braid: updated cache for source :001", + "Braid: fetching source :001", + "Braid: fetched source :001", + "Braid: checking source :001", + "Braid: checked source :001", ) - assertNotContains(t, stderr, "Braid: updating mirror vendor/basic") + assertNotContains(t, stderr, "Braid: updating source :001") testutil.WriteFile(t, upstream, "README.md", "remote\n") revision := testutil.CommitAll(t, upstream, "remote") stdout, stderr = runCommandOKWithOutput(t, repo, []string{"pull", "vendor/basic"}) assertEmptyOutput(t, "pull stdout", stdout) assertInOrder(t, stderr, - "Braid: updating cache for mirror vendor/basic", - "Braid: fetching mirror vendor/basic", - "Braid: checking mirror vendor/basic", - "Braid: checked mirror vendor/basic", - "Braid: updating mirror vendor/basic", - "Braid: updated mirror vendor/basic to "+revision[:7], + "Braid: updating cache for source :001", + "Braid: fetching source :001", + "Braid: checking source :001", + "Braid: checked source :001", + "Braid: updating source :001", + "Braid: updated source :001 to "+revision[:7], ) assertFile(t, repo, "vendor/basic/README.md", "remote\n") @@ -92,17 +92,17 @@ func TestProgressStatusKeepsDataOnStdoutAndQuietSuppresses(t *testing.T) { stdout, stderr := runCommandOKWithOutput(t, repo, []string{"status", "vendor/basic"}) assertContains(t, stdout, "vendor/basic (") - assertContains(t, stdout, "(Remote Modified)") + assertContains(t, stdout, "Modified Remotely") assertInOrder(t, stderr, - "Braid: updating cache for mirror vendor/basic", - "Braid: updated cache for mirror vendor/basic", - "Braid: fetching mirror vendor/basic", - "Braid: fetched mirror vendor/basic", + "Braid: updating cache for source :001", + "Braid: updated cache for source :001", + "Braid: fetching source :001", + "Braid: fetched source :001", ) stdout, stderr = runCommandOKWithOutput(t, repo, []string{"--quiet", "status", "vendor/basic"}) assertContains(t, stdout, "vendor/basic (") - assertContains(t, stdout, "(Remote Modified)") + assertContains(t, stdout, "Modified Remotely") assertNoProgressOutput(t, stderr) } @@ -120,10 +120,10 @@ func TestProgressDiffHydrationKeepsDataOnStdoutAndQuietSuppresses(t *testing.T) assertContains(t, stdout, "diff --git a/README.md b/README.md") assertContains(t, stdout, "changed") assertInOrder(t, stderr, - "Braid: updating cache for mirror vendor/basic", - "Braid: updated cache for mirror vendor/basic", - "Braid: fetching mirror vendor/basic", - "Braid: fetched mirror vendor/basic", + "Braid: updating cache for source :001", + "Braid: updated cache for source :001", + "Braid: fetching source :001", + "Braid: fetched source :001", ) quietClone := cloneWithoutBaseRevision(t, repo, revision) @@ -149,17 +149,17 @@ func TestProgressPushReportsRemoteOperationsAndQuietPreservesResults(t *testing. stdout, stderr := runCommandOKWithOutput(t, repo, []string{"push", "vendor/basic"}) assertContains(t, stdout, "Push progress") assertInOrder(t, stderr, - "Braid: updating cache for mirror vendor/basic", - "Braid: updated cache for mirror vendor/basic", - "Braid: fetching mirror vendor/basic", - "Braid: fetched mirror vendor/basic", - "Braid: pushing mirror vendor/basic", - "Braid: pushed mirror vendor/basic", + "Braid: updating cache for source :001", + "Braid: updated cache for source :001", + "Braid: fetching source :001", + "Braid: fetched source :001", + "Braid: pushing source :001", + "Braid: pushed source :001", ) assertFile(t, upstream, "README.md", "local\n") stdout, stderr = runCommandOKWithOutput(t, repo, []string{"--quiet", "push", "vendor/basic"}) - assertContains(t, stdout, "Braid: Mirror is not up to date. Stopping.") + assertContains(t, stdout, "Braid: Source is not up to date. Stopping.") assertNoProgressOutput(t, stderr) } diff --git a/internal/command/push.go b/internal/command/push.go index 5b447f3..7ef3480 100644 --- a/internal/command/push.go +++ b/internal/command/push.go @@ -2,15 +2,17 @@ package command import ( "context" + "errors" "fmt" "io" "os" "path/filepath" + "strings" "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type PushHandler struct { @@ -25,6 +27,85 @@ const ( pushStatusNotUpToDate ) +type pushRepresentation struct { + Mirror source.Mirror + Item gitexec.TreeItem + Present bool +} + +func validatePushRepresentations(ctx context.Context, git PushGit, items []pushRepresentation) error { + for i := range items { + for j := i + 1; j < len(items); j++ { + a, b := items[i], items[j] + switch { + case a.Mirror.UpstreamPath == b.Mirror.UpstreamPath: + if a.Present != b.Present || (a.Present && !sameTreeItem(a.Item, b.Item)) { + return inconsistentPushMirrors(a, b) + } + case upstreamContains(a.Mirror.UpstreamPath, b.Mirror.UpstreamPath): + if ok, err := pushDescendantMatches(ctx, git, a, b); err != nil { + return err + } else if !ok { + return inconsistentPushMirrors(a, b) + } + case upstreamContains(b.Mirror.UpstreamPath, a.Mirror.UpstreamPath): + if ok, err := pushDescendantMatches(ctx, git, b, a); err != nil { + return err + } else if !ok { + return inconsistentPushMirrors(a, b) + } + } + } + } + return nil +} +func upstreamContains(ancestor, descendant string) bool { + if ancestor == "" { + return descendant != "" + } + return strings.HasPrefix(descendant, ancestor+"/") +} +func pushDescendantMatches(ctx context.Context, git PushGit, ancestor, descendant pushRepresentation) (bool, error) { + if !ancestor.Present { + return !descendant.Present, nil + } + if ancestor.Item.Type != "tree" { + return false, nil + } + relative := descendant.Mirror.UpstreamPath + if ancestor.Mirror.UpstreamPath != "" { + relative = strings.TrimPrefix(relative, ancestor.Mirror.UpstreamPath+"/") + } + nested, err := git.LsTreeItem(ctx, ancestor.Item.Hash, relative) + nestedPresent := true + if gitexec.IsTreeItemNotFound(err) { + nestedPresent = false + err = nil + } + if err != nil { + return false, err + } + return nestedPresent == descendant.Present && (!nestedPresent || sameTreeItem(nested, descendant.Item)), nil +} +func inconsistentPushMirrors(a, b pushRepresentation) error { + return fmt.Errorf("source mirrors %s -> %q and %s -> %q represent inconsistent upstream content", a.Mirror.LocalPath, a.Mirror.UpstreamPath, b.Mirror.LocalPath, b.Mirror.UpstreamPath) +} +func outermostPushRepresentation(items []pushRepresentation, index int) bool { + candidate := items[index] + for i, other := range items { + if i == index { + continue + } + if other.Mirror.UpstreamPath == candidate.Mirror.UpstreamPath && other.Mirror.LocalPath < candidate.Mirror.LocalPath { + return false + } + if upstreamContains(other.Mirror.UpstreamPath, candidate.Mirror.UpstreamPath) { + return false + } + } + return true +} + type pushResult struct { Status pushStatus } @@ -44,21 +125,17 @@ func (h PushHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err := validateConfigPaths(cfg); err != nil { return err } - localPath, err := normalizeLocalPath(repo, inv.Push.LocalPath) - if err != nil { - return err - } - m, err := cfg.GetRequired(localPath) + selection, err := resolveSourceSelection(repo, cfg, inv.Push.LocalPath, false) if err != nil { return err } - result, err := h.push(ctx, repo, git, m, inv.Push.Branch, inv.Push.Keep, inv.Push.Message, inv.Global, stdout, stderr) + result, err := h.push(ctx, repo, git, selection.Source.WithMirror(selection.Mirrors[0]), inv.Push.Branch, inv.Push.Keep, inv.Push.Message, inv.Global, stdout, stderr) if err != nil { return err } switch result.Status { case pushStatusNotUpToDate: - _, err = fmt.Fprintln(stdout, "Braid: Mirror is not up to date. Stopping.") + _, err = fmt.Fprintln(stdout, "Braid: Source is not up to date. Stopping.") case pushStatusNoLocalChanges: _, err = fmt.Fprintln(stdout, "Braid: No local changes found in downstream HEAD. Stopping.") } @@ -75,12 +152,27 @@ func (h PushHandler) pushGit(repo RepoContext, inv cli.Invocation, trace io.Writ return gitexec.New(repo.GitWorkTreeRoot, inv.Global.Verbose, trace) } -func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m mirror.Mirror, branch string, keep bool, commitMessage string, global cli.GlobalOptions, stdout, stderr io.Writer) (result pushResult, err error) { +func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m source.SourceMirror, branch string, keep bool, commitMessage string, global cli.GlobalOptions, stdout, stderr io.Writer) (result pushResult, err error) { if branch == "" { - branch = m.Branch + branch = m.Branch() } if branch == "" { - return pushResult{}, fmt.Errorf("mirror has no tracked branch; specify --branch to push %s", m.Path) + return pushResult{}, fmt.Errorf("mirror has no tracked branch; specify --branch to push %s", m.LocalPath) + } + ls, ok := git.(interface { + LsRemote(context.Context, ...string) (string, error) + }) + if !ok { + return pushResult{}, errors.New("git implementation cannot inspect push destination") + } + out, lsErr := ls.LsRemote(ctx, m.URL, "refs/heads/"+branch) + if lsErr != nil { + return pushResult{}, lsErr + } + expectedOld := "" + fields := strings.Fields(out) + if len(fields) > 0 { + expectedOld = fields[0] } progress := newProgressReporter(stderr, global.Quiet) @@ -89,42 +181,66 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m return pushResult{}, err } if cache.Enabled { - if err := fetchCache(ctx, cache, m, global.Verbose, progress, stderr); err != nil { + cacheMirror := m + if expectedOld == "" { + cacheMirror.Tracking = source.RevisionTracking{} + } + if err := fetchCache(ctx, cache, cacheMirror, global.Verbose, progress, stderr); err != nil { return pushResult{}, err } } + previousRemoteURL, previousRemoteExists, err := git.RemoteURL(ctx, m.Remote()) + if err != nil { + return pushResult{}, err + } + var previousRemoteConfig gitexec.RemoteConfigSnapshot + if previousRemoteExists { + if exact, ok := git.(exactRemoteConfigGit); ok { + previousRemoteConfig, err = exact.SnapshotRemoteConfig(ctx, m.Remote()) + if err != nil { + return pushResult{}, err + } + } + } if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return pushResult{}, err } - if !keep { - defer func() { - removeErr := git.RemoteRemove(ctx, m.Remote()) - if err == nil { + defer func() { + if keep && err == nil { + return + } + if _, ok, inspectErr := git.RemoteURL(ctx, m.Remote()); inspectErr == nil && ok { + if removeErr := git.RemoteRemove(ctx, m.Remote()); removeErr != nil && err == nil { err = removeErr } - }() - } + } else if inspectErr != nil && err == nil { + err = inspectErr + } + if err != nil && previousRemoteExists { + var restoreErr error + if exact, ok := git.(exactRemoteConfigGit); ok && previousRemoteConfig != nil { + restoreErr = exact.RestoreRemoteConfig(ctx, m.Remote(), previousRemoteConfig) + } else { + restoreErr = git.RemoteAdd(ctx, m.Remote(), previousRemoteURL) + } + if restoreErr != nil { + err = fmt.Errorf("%w; failed to restore existing remote: %w", err, restoreErr) + } + } + }() if err := fetchMirror(ctx, git, cache, m, progress); err != nil { return pushResult{}, err } - upstreamRevision, err := resolveAddRevision(ctx, git, m, "") - if err != nil { - return pushResult{}, err - } baseRevision, err := git.RevParse(ctx, m.Revision+"^{commit}") if err != nil { return pushResult{}, err } - if upstreamRevision != baseRevision { + if expectedOld != "" && expectedOld != baseRevision { return pushResult{Status: pushStatusNotUpToDate}, nil } - localItem, err := git.LsTreeItem(ctx, "HEAD", m.Path) - if err != nil { - return pushResult{}, err - } - newTree, err := git.MakeTreeWithItemIn(ctx, baseRevision, m.RemotePath, localItem) + newTree, err := reconstructUpstreamTree(ctx, git, m) if err != nil { return pushResult{}, err } @@ -148,10 +264,11 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m messageGeneration = configuredPushMessageGeneration() } + pushCompleted := false if err := runProgressWithOperation( progress, - fmt.Sprintf("Braid: pushing mirror %s", m.Path), - fmt.Sprintf("Braid: pushed mirror %s", m.Path), + fmt.Sprintf("Braid: pushing source :%s", m.Name), + fmt.Sprintf("Braid: pushed source :%s", m.Name), func(pushProgress *progressOperation) error { info := stderr commitStdout := stdout @@ -159,15 +276,85 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m info = io.Discard commitStdout = io.Discard } - return h.pushViaTempRepo(ctx, repo, git, m, branch, baseRevision, newTree, commitMessage, global.Verbose, h.stdin(), commitStdout, stderr, info, pushProgress, provenance, provenanceOK, provenanceErr, messageGeneration) + pushErr := h.pushViaTempRepo(ctx, repo, git, m, branch, baseRevision, expectedOld, newTree, commitMessage, global.Verbose, h.stdin(), commitStdout, stderr, info, pushProgress, provenance, provenanceOK, provenanceErr, messageGeneration) + pushCompleted = pushErr == nil + return pushErr }, ); err != nil { + if pushCompleted { + return pushResult{Status: pushStatusPushed}, err + } return pushResult{}, err } return pushResult{Status: pushStatusPushed}, nil } -func (h PushHandler) pushViaTempRepo(ctx context.Context, repo RepoContext, source PushGit, m mirror.Mirror, branch, baseRevision, newTree, commitMessage string, verbose bool, stdin io.Reader, stdout, stderr, info io.Writer, pushProgress *progressOperation, provenance pushProvenance, provenanceOK bool, provenanceErr error, messageGeneration pushMessageGeneration) error { +func reconstructUpstreamTree(ctx context.Context, git PushGit, m source.SourceMirror) (string, error) { + representations := make([]pushRepresentation, 0, len(m.Mirrors)) + for _, mirror := range m.SortedMirrors() { + if contains, containsErr := git.TreeContainsGitlink(ctx, "HEAD", mirror.LocalPath); containsErr != nil { + return "", containsErr + } else if contains { + return "", fmt.Errorf("mirror %s contains an unsupported gitlink", mirror.LocalPath) + } + item, itemErr := git.LsTreeItem(ctx, "HEAD", mirror.LocalPath) + present := true + if gitexec.IsTreeItemNotFound(itemErr) { + present = false + itemErr = nil + } + if itemErr != nil { + return "", itemErr + } + if present && (item.Type == "commit" || item.Mode == "160000") { + return "", fmt.Errorf("mirror %s contains an unsupported gitlink", mirror.LocalPath) + } + representations = append(representations, pushRepresentation{Mirror: mirror, Item: item, Present: present}) + } + if err := validatePushRepresentations(ctx, git, representations); err != nil { + return "", err + } + newTree := m.Revision + var err error + for i, representation := range representations { + if !outermostPushRepresentation(representations, i) { + continue + } + upstream := representation.Mirror.UpstreamPath + if upstream == "" { + if representation.Present { + if representation.Item.Type != "tree" { + return "", fmt.Errorf("root mirror %s is not a tree", representation.Mirror.LocalPath) + } + newTree = representation.Item.Hash + } else { + empty, ok := git.(interface { + EmptyTree(context.Context) (string, error) + }) + if !ok { + return "", errors.New("git implementation cannot create empty tree") + } + newTree, err = empty.EmptyTree(ctx) + } + } else if representation.Present { + newTree, err = git.MakeTreeWithItemIn(ctx, newTree, upstream, representation.Item) + } else { + remover, ok := git.(interface { + MakeTreeWithoutPath(context.Context, string, string) (string, error) + }) + if !ok { + return "", errors.New("git implementation cannot remove upstream paths") + } + newTree, err = remover.MakeTreeWithoutPath(ctx, newTree, upstream) + } + if err != nil { + return "", err + } + } + return newTree, nil +} + +func (h PushHandler) pushViaTempRepo(ctx context.Context, repo RepoContext, source PushGit, m source.SourceMirror, branch, baseRevision, expectedOld, newTree, commitMessage string, verbose bool, stdin io.Reader, stdout, stderr, info io.Writer, pushProgress *progressOperation, provenance pushProvenance, provenanceOK bool, provenanceErr error, messageGeneration pushMessageGeneration) error { workspaceDir, err := os.MkdirTemp("", "braid-push") if err != nil { return err @@ -255,7 +442,8 @@ func (h PushHandler) pushViaTempRepo(ctx context.Context, repo RepoContext, sour return err } } - return tempGit.Push(ctx, m.URL, "HEAD:refs/heads/"+branch) + lease := "--force-with-lease=refs/heads/" + branch + ":" + expectedOld + return tempGit.Push(ctx, lease, m.URL, "HEAD:refs/heads/"+branch) } func (h PushHandler) stdin() io.Reader { diff --git a/internal/command/push_message.go b/internal/command/push_message.go index 2213444..8b16bdb 100644 --- a/internal/command/push_message.go +++ b/internal/command/push_message.go @@ -13,7 +13,7 @@ import ( "unicode/utf8" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) const ( @@ -47,7 +47,7 @@ type pushMessageDiffContext struct { } type pushMessagePromptData struct { - Mirror mirror.Mirror + Mirror source.SourceMirror Branch string BaseRevision string NewTree string @@ -78,7 +78,7 @@ func configuredPushMessageGeneration() pushMessageGeneration { return pushMessageGeneration{Enabled: true, CommandTemplate: value} } -func preparePushMessageSeed(ctx context.Context, repo RepoContext, source PushGit, tempGit gitexec.Git, m mirror.Mirror, branch, baseRevision, newTree, contextDir string, generation pushMessageGeneration, verbose bool, trace io.Writer, provenance pushProvenance, provenanceOK bool, provenanceErr error) (string, error) { +func preparePushMessageSeed(ctx context.Context, repo RepoContext, source PushGit, tempGit gitexec.Git, m source.SourceMirror, branch, baseRevision, newTree, contextDir string, generation pushMessageGeneration, verbose bool, trace io.Writer, provenance pushProvenance, provenanceOK bool, provenanceErr error) (string, error) { if err := validatePushMessageGeneratorPlatform(runtime.GOOS); err != nil { return "", err } @@ -121,7 +121,7 @@ func preparePushMessageSeed(ctx context.Context, repo RepoContext, source PushGi if progress == nil { progress = io.Discard } - if _, err := fmt.Fprintf(progress, "Braid: generating push commit message for %s using external tool\n", m.Path); err != nil { + if _, err := fmt.Fprintf(progress, "Braid: generating push commit message for %s using external tool\n", m.LocalPath); err != nil { return "", err } generated, failure, err := runPushMessageGenerator(ctx, generation.CommandTemplate, pushMessageCommandValues{ @@ -164,8 +164,8 @@ func pushMessageSeedCommentChar(ctx context.Context, git PushGit) (string, error return value, nil } -func pushMessageDiff(ctx context.Context, git gitexec.Git, m mirror.Mirror) (string, error) { - return git.Diff(ctx, pushMessageDiffArgs(m.RemotePath)...) +func pushMessageDiff(ctx context.Context, git gitexec.Git, m source.SourceMirror) (string, error) { + return git.Diff(ctx, pushMessageDiffArgs("")...) } func pushMessageDiffArgs(remotePath string) []string { @@ -193,17 +193,20 @@ func writePushMessageDiffContext(contextDir, diff string) (pushMessageDiffContex func formatPushMessagePrompt(data pushMessagePromptData) string { var b strings.Builder b.WriteString("Generate a Git commit message for an upstream commit created by braid push.\n") - b.WriteString("The commit will be written to the mirrored/upstream repository, not to the downstream/hosting repository that contains the local mirror.\n") + b.WriteString("The commit will be written to the mirrored/upstream repository, not to the downstream/hosting repository that contains the local source.\n") b.WriteString("Describe the staged mirror changes from the mirrored repository's perspective: focus on what changed in the upstream project files at the upstream path below.\n") b.WriteString("Use downstream commit provenance only as background for intent; do not frame the message around vendoring, updating a mirror, syncing from the hosting repository, local mirror paths, or .braids.json unless those are part of the upstream content change itself.\n") b.WriteString("Respond only with the proposed commit message. Do not include commentary, Markdown fences, explanations, or any other content. The user will review the message in Git's editor before Braid commits.\n\n") - b.WriteString("Mirror metadata:\n") - fmt.Fprintf(&b, "- Local mirror path: %s\n", data.Mirror.Path) + b.WriteString("Source metadata:\n") + fmt.Fprintf(&b, "- Source name: %s\n", data.Mirror.Name) fmt.Fprintf(&b, "- Upstream URL: %s\n", data.Mirror.URL) - if data.Mirror.RemotePath == "" { - b.WriteString("- Upstream path: (repository root)\n") - } else { - fmt.Fprintf(&b, "- Upstream path: %s\n", data.Mirror.RemotePath) + b.WriteString("- Mirrors:\n") + for _, mirror := range data.Mirror.SortedMirrors() { + upstream := mirror.UpstreamPath + if upstream == "" { + upstream = "(repository root)" + } + fmt.Fprintf(&b, " - %s -> %s\n", mirror.LocalPath, upstream) } fmt.Fprintf(&b, "- Recorded base revision: %s\n", data.BaseRevision) fmt.Fprintf(&b, "- Synthetic upstream tree: %s\n", data.NewTree) @@ -319,7 +322,7 @@ func expandPushMessageCommand(commandTemplate string, values pushMessageCommandV return replacer.Replace(commandTemplate) } -func formatPushMessageSuccessSeed(commentChar, generatedMessage string, m mirror.Mirror, provenance pushProvenance, provenanceOK bool) string { +func formatPushMessageSuccessSeed(commentChar, generatedMessage string, m source.SourceMirror, provenance pushProvenance, provenanceOK bool) string { var b strings.Builder b.WriteString(strings.TrimSpace(generatedMessage)) b.WriteString("\n") @@ -330,7 +333,7 @@ func formatPushMessageSuccessSeed(commentChar, generatedMessage string, m mirror return b.String() } -func formatPushMessageFailureSeed(commentChar string, m mirror.Mirror, provenance pushProvenance, provenanceOK bool, failure pushMessageGeneratorFailure) string { +func formatPushMessageFailureSeed(commentChar string, m source.SourceMirror, provenance pushProvenance, provenanceOK bool, failure pushMessageGeneratorFailure) string { lines := []string{ "Braid AI push commit-message generation failed.", "Reason: " + failure.Summary, diff --git a/internal/command/push_provenance.go b/internal/command/push_provenance.go index 56d64ca..0da53f4 100644 --- a/internal/command/push_provenance.go +++ b/internal/command/push_provenance.go @@ -3,12 +3,13 @@ package command import ( "context" "fmt" + "sort" "strings" "unicode/utf8" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) const pushProvenanceCommitLimit = 25 @@ -34,7 +35,47 @@ type pushProvenanceWindow struct { NoCleanAnchor bool } -func buildPushProvenance(ctx context.Context, git PushGit, m mirror.Mirror) (pushProvenance, bool, error) { +func buildPushProvenance(ctx context.Context, git PushGit, m source.SourceMirror) (pushProvenance, bool, error) { + byHash := map[string]pushProvenanceCommit{} + noAnchor := false + for _, mirror := range m.SortedMirrors() { + one, ok, err := buildPushProvenanceOne(ctx, git, m.WithMirror(mirror)) + if err != nil { + return pushProvenance{}, false, err + } + if !ok { + continue + } + noAnchor = noAnchor || one.NoCleanAnchor + for _, commit := range one.Commits { + byHash[commit.Hash] = commit + } + } + if len(byHash) == 0 { + return pushProvenance{}, false, nil + } + order, err := git.FirstParentCommits(ctx, "HEAD") + if err != nil { + return pushProvenance{}, false, err + } + rank := map[string]int{} + for i, hash := range order { + rank[hash] = i + } + commits := make([]pushProvenanceCommit, 0, len(byHash)) + for _, commit := range byHash { + commits = append(commits, commit) + } + sort.Slice(commits, func(i, j int) bool { return rank[commits[i].Hash] > rank[commits[j].Hash] }) + omitted := 0 + if len(commits) > pushProvenanceCommitLimit { + omitted = len(commits) - pushProvenanceCommitLimit + commits = commits[omitted:] + } + return pushProvenance{Commits: commits, Omitted: omitted, NoCleanAnchor: noAnchor}, true, nil +} + +func buildPushProvenanceOne(ctx context.Context, git PushGit, m source.SourceMirror) (pushProvenance, bool, error) { window, err := findPushProvenanceWindow(ctx, git, m) if err != nil { return pushProvenance{}, false, err @@ -53,7 +94,7 @@ func buildPushProvenance(ctx context.Context, git PushGit, m mirror.Mirror) (pus }, true, nil } -func buildPushProvenanceTemplateFromRaw(ctx context.Context, git PushGit, m mirror.Mirror, provenance pushProvenance) (pushProvenanceTemplate, bool, error) { +func buildPushProvenanceTemplateFromRaw(ctx context.Context, git PushGit, m source.SourceMirror, provenance pushProvenance) (pushProvenanceTemplate, bool, error) { if len(provenance.Commits) == 0 { return pushProvenanceTemplate{}, false, nil } @@ -84,13 +125,13 @@ func pushProvenanceCommentChar(ctx context.Context, git PushGit) (string, error) return value, nil } -func findPushProvenanceWindow(ctx context.Context, git PushGit, current mirror.Mirror) (pushProvenanceWindow, error) { +func findPushProvenanceWindow(ctx context.Context, git PushGit, current source.SourceMirror) (pushProvenanceWindow, error) { commits, err := git.FirstParentCommits(ctx, "HEAD") if err != nil { return pushProvenanceWindow{}, err } for _, commit := range commits { - historical, ok, err := mirrorAtCommit(ctx, git, commit, current.Path) + historical, ok, err := mirrorAtCommit(ctx, git, commit, current.LocalPath) if err != nil { return pushProvenanceWindow{}, err } @@ -108,8 +149,8 @@ func findPushProvenanceWindow(ctx context.Context, git PushGit, current mirror.M return pushProvenanceWindow{Range: "HEAD", NoCleanAnchor: true}, nil } -func mirrorCleanAtCommit(ctx context.Context, git PushGit, commit string, m mirror.Mirror) (bool, error) { - downstreamItem, err := git.LsTreeItem(ctx, commit, m.Path) +func mirrorCleanAtCommit(ctx context.Context, git PushGit, commit string, m source.SourceMirror) (bool, error) { + downstreamItem, err := git.LsTreeItem(ctx, commit, m.LocalPath) if err != nil { if !isMissingTreeItemError(err) { return false, err @@ -123,21 +164,21 @@ func mirrorCleanAtCommit(ctx context.Context, git PushGit, commit string, m mirr return sameTreeItem(downstreamItem, upstreamItem), nil } -func recordedMirrorItem(ctx context.Context, git PushGit, m mirror.Mirror) (gitexec.TreeItem, error) { - if m.RemotePath == "" { +func recordedMirrorItem(ctx context.Context, git PushGit, m source.SourceMirror) (gitexec.TreeItem, error) { + if m.UpstreamPath == "" { return git.TreeItem(ctx, m.Revision) } - return git.LsTreeItem(ctx, m.Revision, m.RemotePath) + return git.LsTreeItem(ctx, m.Revision, m.UpstreamPath) } -func collectPushProvenanceCommits(ctx context.Context, git PushGit, current mirror.Mirror, revisionRange string) ([]pushProvenanceCommit, int, error) { - candidates, err := git.LogCommitsTouchingPath(ctx, revisionRange, current.Path) +func collectPushProvenanceCommits(ctx context.Context, git PushGit, current source.SourceMirror, revisionRange string) ([]pushProvenanceCommit, int, error) { + candidates, err := git.LogCommitsTouchingPath(ctx, revisionRange, current.LocalPath) if err != nil { return nil, 0, err } eligible := make([]pushProvenanceCommit, 0, len(candidates)) for _, candidate := range candidates { - historical, ok, err := mirrorAtCommit(ctx, git, candidate.Hash, current.Path) + historical, ok, err := mirrorAtCommit(ctx, git, candidate.Hash, current.LocalPath) if err != nil { return nil, 0, err } @@ -149,34 +190,34 @@ func collectPushProvenanceCommits(ctx context.Context, git PushGit, current mirr Message: candidate.Message, }) } - omitted := 0 - if len(eligible) > pushProvenanceCommitLimit { - omitted = len(eligible) - pushProvenanceCommitLimit - eligible = eligible[omitted:] - } - return eligible, omitted, nil + return eligible, 0, nil } -func mirrorAtCommit(ctx context.Context, git PushGit, commit, localPath string) (mirror.Mirror, bool, error) { +func mirrorAtCommit(ctx context.Context, git PushGit, commit, localPath string) (source.SourceMirror, bool, error) { data, ok, err := git.ShowFile(ctx, commit, config.FileName) if err != nil { - return mirror.Mirror{}, false, err + return source.SourceMirror{}, false, err } if !ok { - return mirror.Mirror{}, false, nil + return source.SourceMirror{}, false, nil } cfg, err := config.Parse(data) if err != nil { - return mirror.Mirror{}, false, fmt.Errorf("parse %s at %s: %w", config.FileName, commit, err) + if strings.Contains(err.Error(), "requires upgrade") { + return source.SourceMirror{}, false, nil + } + return source.SourceMirror{}, false, fmt.Errorf("parse %s at %s: %w", config.FileName, commit, err) + } + s, m, ok := cfg.MirrorByLocalPath(localPath) + if !ok { + return source.SourceMirror{}, false, nil } - m, ok := cfg.Get(localPath) - return m, ok, nil + return s.WithMirror(m), true, nil } -func samePushProvenanceIdentity(current, historical mirror.Mirror) bool { - return current.Path == historical.Path && - current.URL == historical.URL && - current.RemotePath == historical.RemotePath +func samePushProvenanceIdentity(current, historical source.SourceMirror) bool { + return current.LocalPath == historical.LocalPath && + current.Name == historical.Name && source.URLIdentity(current.URL) == source.URLIdentity(historical.URL) && current.TrackingIdentity() == historical.TrackingIdentity() && current.UpstreamPath == historical.UpstreamPath } func sameTreeItem(a, b gitexec.TreeItem) bool { @@ -188,6 +229,10 @@ func isBraidAutomaticMirrorCommit(subject string) bool { "Braid: Add mirror ", "Braid: Update mirror ", "Braid: Remove mirror ", + "Braid: Add source ", + "Braid: Update source ", + "Braid: Remove source ", + "Braid: Add mirrors to source ", } { if strings.HasPrefix(subject, prefix) { return true @@ -196,9 +241,9 @@ func isBraidAutomaticMirrorCommit(subject string) bool { return false } -func formatPushProvenanceTemplate(m mirror.Mirror, commits []pushProvenanceCommit, omitted int, noCleanAnchor bool, commentChar string) string { +func formatPushProvenanceTemplate(m source.SourceMirror, commits []pushProvenanceCommit, omitted int, noCleanAnchor bool, commentChar string) string { lines := []string{ - fmt.Sprintf("Braid downstream mirror commit guidance for %s", m.Path), + fmt.Sprintf("Braid downstream mirror commit guidance for %s", m.LocalPath), "", } if noCleanAnchor { diff --git a/internal/command/push_test.go b/internal/command/push_test.go index a74b2a2..403c4c7 100644 --- a/internal/command/push_test.go +++ b/internal/command/push_test.go @@ -11,7 +11,6 @@ import ( "testing" "braid/internal/config" - "braid/internal/mirror" "braid/internal/testutil" ) @@ -89,7 +88,8 @@ func TestPushCommandEditorReceivesStdin(t *testing.T) { func TestPushCommandPushesExplicitBranch(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") - testutil.CommitAll(t, upstream, "base") + base := testutil.CommitAll(t, upstream, "base") + testutil.Git(t, upstream, "branch", "feature", base) repo := initDownstream(t) runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) @@ -102,6 +102,46 @@ func TestPushCommandPushesExplicitBranch(t *testing.T) { assertFile(t, upstream, "README.md", "feature\n") } +func TestPushCommandRejectsDivergedExplicitBranch(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + base := testutil.CommitAll(t, upstream, "base") + testutil.Git(t, upstream, "checkout", "-b", "feature", base) + testutil.WriteFile(t, upstream, "feature.txt", "diverged\n") + feature := testutil.CommitAll(t, upstream, "diverge feature") + testutil.Git(t, upstream, "checkout", "main") + + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) + testutil.WriteFile(t, repo, "vendor/basic/README.md", "local\n") + testutil.CommitAll(t, repo, "local mirror change") + out := runCommandOK(t, repo, []string{"push", "vendor/basic", "--branch", "feature", "--message", "must not push"}) + assertContains(t, out, "Braid: Source is not up to date. Stopping.") + if got := strings.TrimSpace(testutil.Git(t, upstream, "rev-parse", "feature").Stdout); got != feature { + t.Fatalf("feature moved to %s, want %s", got, feature) + } +} + +func TestPushCommandRecreatesDeletedTrackedBranch(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "README.md", "base\n") + testutil.CommitAll(t, upstream, "base") + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) + testutil.WriteFile(t, repo, "vendor/basic/README.md", "recreated\n") + testutil.CommitAll(t, repo, "local mirror change") + testutil.Git(t, upstream, "checkout", "-b", "holding") + testutil.Git(t, upstream, "branch", "-D", "main") + if err := os.RemoveAll(filepath.Join(repo, ".git", "braid", "cache")); err != nil { + t.Fatal(err) + } + + runCommandOK(t, repo, []string{"push", "vendor/basic", "--message", "recreate main"}) + if got := strings.TrimSpace(testutil.Git(t, upstream, "show", "main:README.md").Stdout); got != "recreated" { + t.Fatalf("recreated main README = %q", got) + } +} + func TestPushCommandPathVariants(t *testing.T) { tests := []struct { name string @@ -117,7 +157,7 @@ func TestPushCommandPathVariants(t *testing.T) { t.Helper() testutil.WriteFile(t, upstream, "lib/component.txt", "base\n") }, - addArgs: func(upstream string) []string { return []string{"add", upstream, "vendor/lib", "--path", "lib"} }, + addArgs: func(upstream string) []string { return []string{"add", upstream, "vendor/lib=lib"} }, localPath: "vendor/lib", localFile: "vendor/lib/component.txt", remoteFile: "lib/component.txt", @@ -129,7 +169,7 @@ func TestPushCommandPathVariants(t *testing.T) { testutil.WriteFile(t, upstream, "lay outs/layout.txt", "base\n") }, addArgs: func(upstream string) []string { - return []string{"add", upstream, "vendor/path with spaces", "--path", "lay outs"} + return []string{"add", upstream, "vendor/path with spaces=lay outs"} }, localPath: "vendor/path with spaces", localFile: "vendor/path with spaces/layout.txt", @@ -142,7 +182,7 @@ func TestPushCommandPathVariants(t *testing.T) { testutil.WriteFile(t, upstream, "LICENSE.txt", "base\n") }, addArgs: func(upstream string) []string { - return []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"} + return []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"} }, localPath: "licenses/THIRD_PARTY.txt", localFile: "licenses/THIRD_PARTY.txt", @@ -356,9 +396,9 @@ func TestPushCommandGeneratedMessagePromptAndReview(t *testing.T) { "Describe the staged mirror changes from the mirrored repository's perspective", "Use downstream commit provenance only as background for intent", "Respond only with the proposed commit message.", - "Local mirror path: vendor/basic", + "Source name: 001", "Upstream URL: " + upstream, - "Upstream path: (repository root)", + "vendor/basic -> (repository root)", "Target branch: main", "Commit " + localCommit, "local generated change", @@ -883,7 +923,7 @@ func TestPushCommandProvenanceMirrorIdentityChangeResetsWindow(t *testing.T) { runCommandOK(t, repo, []string{"add", upstreamA, "vendor/basic"}) testutil.WriteFile(t, repo, "vendor/basic/README.md", "old local\n") oldCommit := commitAllWithMessage(t, repo, "old identity local") - writeSingleMirrorConfig(t, repo, mirror.Mirror{Path: "vendor/basic", URL: upstreamB, Branch: "main", Revision: bRevision}) + writeSingleMirrorConfig(t, repo, testSourceMirror("vendor/basic", "", upstreamB, "main", "", bRevision, false)) testutil.WriteFile(t, repo, "vendor/basic/README.md", "b base\n") commitAllWithMessage(t, repo, "switch mirror identity") testutil.WriteFile(t, repo, "vendor/basic/README.md", "new local\n") @@ -916,7 +956,7 @@ func TestPushCommandProvenanceExcludesMergedSideBranchBeforeCurrentIdentity(t *t testutil.WriteFile(t, repo, "vendor/basic/old-side.txt", "old side\n") oldSideCommit := commitAllWithMessage(t, repo, "old side identity") testutil.Git(t, repo, "checkout", "main") - writeSingleMirrorConfig(t, repo, mirror.Mirror{Path: "vendor/basic", URL: upstreamB, Branch: "main", Revision: bRevision}) + writeSingleMirrorConfig(t, repo, testSourceMirror("vendor/basic", "", upstreamB, "main", "", bRevision, false)) testutil.WriteFile(t, repo, "vendor/basic/README.md", "b base\n") commitAllWithMessage(t, repo, "switch to new identity") testutil.Git(t, repo, "merge", "--no-ff", "--no-commit", "old-side") @@ -941,7 +981,7 @@ func TestPushCommandProvenanceRootWithoutCleanAnchorShowsNote(t *testing.T) { testutil.Git(t, upstream, "config", "receive.denyCurrentBranch", "updateInstead") repo := testutil.InitRepo(t) - writeSingleMirrorConfig(t, repo, mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: revision}) + writeSingleMirrorConfig(t, repo, testSourceMirror("vendor/basic", "", upstream, "main", "", revision, false)) testutil.WriteFile(t, repo, "vendor/basic/README.md", "dirty from root\n") rootCommit := commitAllWithMessage(t, repo, "root dirty mirror") capture, editor := writeCapturingEditor(t, "Push root dirty") diff --git a/internal/command/remote.go b/internal/command/remote.go index c004272..4de381a 100644 --- a/internal/command/remote.go +++ b/internal/command/remote.go @@ -3,38 +3,68 @@ package command import ( "context" "errors" + "fmt" "braid/internal/config" - "braid/internal/mirror" + "braid/internal/gitexec" "braid/internal/pathcheck" + "braid/internal/source" ) -func configureMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig) error { +type exactRemoteConfigGit interface { + SnapshotRemoteConfig(context.Context, string) (gitexec.RemoteConfigSnapshot, error) + RestoreRemoteConfig(context.Context, string, gitexec.RemoteConfigSnapshot) error +} + +func configureMirrorRemote(ctx context.Context, git RemoteGit, m source.SourceMirror, force bool, cache CacheConfig) error { return configureMirrorRemoteWithProgress(ctx, git, m, force, cache, progressReporter{}) } -func configureMirrorRemoteWithProgress(ctx context.Context, git RemoteGit, m mirror.Mirror, force bool, cache CacheConfig, progress progressReporter) error { +func configureMirrorRemoteWithProgress(ctx context.Context, git RemoteGit, m source.SourceMirror, force bool, cache CacheConfig, progress progressReporter) error { remote := m.Remote() - if _, ok, err := git.RemoteURL(ctx, remote); err != nil { + if url, ok, err := git.RemoteURL(ctx, remote); err != nil { return err } else if ok { if !force { return nil } - return runProgress(progress, "Braid: setting up mirror remote "+m.Path, "Braid: set up mirror remote "+m.Path, func() error { + return runProgress(progress, "Braid: setting up source remote :"+m.Name, "Braid: set up source remote :"+m.Name, func() error { + var snapshot gitexec.RemoteConfigSnapshot + if exact, ok := git.(exactRemoteConfigGit); ok { + var snapshotErr error + snapshot, snapshotErr = exact.SnapshotRemoteConfig(ctx, remote) + if snapshotErr != nil { + return snapshotErr + } + } if err := git.RemoteRemove(ctx, remote); err != nil { return err } - return addMirrorRemote(ctx, git, m, cache) + if err := addMirrorRemote(ctx, git, m, cache); err != nil { + if exact, ok := git.(exactRemoteConfigGit); ok { + if restoreErr := exact.RestoreRemoteConfig(ctx, remote, snapshot); restoreErr != nil { + return fmt.Errorf("%w; failed to restore existing remote: %w", err, restoreErr) + } + return err + } + if _, ok, inspectErr := git.RemoteURL(ctx, remote); inspectErr == nil && ok { + _ = git.RemoteRemove(ctx, remote) + } + if restoreErr := git.RemoteAdd(ctx, remote, url); restoreErr != nil { + return fmt.Errorf("%w; failed to restore existing remote: %w", err, restoreErr) + } + return err + } + return nil }) } - return runProgress(progress, "Braid: setting up mirror remote "+m.Path, "Braid: set up mirror remote "+m.Path, func() error { + return runProgress(progress, "Braid: setting up source remote :"+m.Name, "Braid: set up source remote :"+m.Name, func() error { return addMirrorRemote(ctx, git, m, cache) }) } -func addMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, cache CacheConfig) error { +func addMirrorRemote(ctx context.Context, git RemoteGit, m source.SourceMirror, cache CacheConfig) error { remote := m.Remote() if err := git.RemoteAdd(ctx, remote, cache.RemoteURL(m)); err != nil { return err @@ -58,22 +88,23 @@ func addMirrorRemote(ctx context.Context, git RemoteGit, m mirror.Mirror, cache func validateConfigPaths(cfg config.Config) error { var existingPaths []string - var existingMirrors []mirror.Mirror - for _, localPath := range cfg.Paths() { - m := cfg.Mirrors[localPath] - if err := pathcheck.ValidateLocal(m.Path, existingPaths); err != nil { + for _, m := range cfg.MirrorsSorted() { + if err := pathcheck.ValidateLocal(m.LocalPath, existingPaths); err != nil { return err } - if m.RemotePath != "" { - if err := pathcheck.ValidateUpstream(m.RemotePath); err != nil { + if m.UpstreamPath != "" { + if err := pathcheck.ValidateUpstream(m.UpstreamPath); err != nil { return err } } - if err := pathcheck.CheckRemoteCollision(m, existingMirrors); err != nil { + existingPaths = append(existingPaths, m.LocalPath) + } + var existingSources []source.Source + for _, s := range cfg.SourcesSorted() { + if err := pathcheck.CheckRemoteCollision(s, existingSources); err != nil { return err } - existingPaths = append(existingPaths, m.Path) - existingMirrors = append(existingMirrors, m) + existingSources = append(existingSources, s) } return nil } diff --git a/internal/command/remove.go b/internal/command/remove.go index b76a3b0..bfeabfe 100644 --- a/internal/command/remove.go +++ b/internal/command/remove.go @@ -5,11 +5,12 @@ import ( "errors" "fmt" "io" + "strings" "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type RemoveHandler struct { @@ -45,22 +46,33 @@ func (h RemoveHandler) remove(ctx context.Context, repo RepoContext, git RemoveG if err := validateConfigPaths(cfg); err != nil { return err } - localPath, err := normalizeLocalPath(repo, options.LocalPath) + selection, err := resolveSourceSelection(repo, cfg, options.LocalPath, false) if err != nil { return err } - m, err := cfg.GetRequired(localPath) - if err != nil { - return err - } - if mirrorOverlapsConfig(m.Path) { - return fmt.Errorf("mirror path %q overlaps %s", m.Path, config.FileName) + s := selection.Source + m := s.WithMirror(selection.Mirrors[0]) + removeSource := strings.HasPrefix(options.LocalPath, ":") || len(s.Mirrors) == 1 + var paths []string + if removeSource { + paths = s.LocalPaths() + } else { + paths = []string{m.LocalPath} + } + for _, path := range paths { + if mirrorOverlapsConfig(path) { + return fmt.Errorf("mirror path %q overlaps %s", path, config.FileName) + } } - if err := ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), true, m.Path); err != nil { + if err := ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), true, paths...); err != nil { return err } - - if err := cfg.Remove(m.Path); err != nil { + if removeSource { + err = cfg.RemoveSource(s.Name) + } else { + _, _, err = cfg.RemoveMirror(m.LocalPath) + } + if err != nil { return err } configData, err := cfg.MarshalJSON() @@ -72,9 +84,12 @@ func (h RemoveHandler) remove(ctx context.Context, repo RepoContext, git RemoveG return err } - treeWithoutMirror, err := git.MakeTreeWithoutPath(ctx, "HEAD", m.Path) - if err != nil { - return err + treeWithoutMirror := "HEAD" + for _, path := range paths { + treeWithoutMirror, err = git.MakeTreeWithoutPath(ctx, treeWithoutMirror, path) + if err != nil { + return err + } } finalTree, err := git.MakeTreeWithItemIn(ctx, treeWithoutMirror, config.FileName, configItem) if err != nil { @@ -82,20 +97,31 @@ func (h RemoveHandler) remove(ctx context.Context, repo RepoContext, git RemoveG } if options.NoCommit { var warned bool + display := m.LocalPath + description := "removal of mirror '" + m.LocalPath + "' from source ':" + s.Name + "'" + if removeSource { + display = ":" + s.Name + description = "" + } if err := stageNoCommitResult(ctx, git, stdout, noCommitStageOptions{ - Tree: finalTree, - Action: "removal", - MirrorPath: m.Path, - Paths: []string{m.Path, config.FileName}, - OwnedPaths: []string{m.Path}, - Quiet: quiet, - Warned: &warned, + Tree: finalTree, + Action: "removal", + MirrorPath: display, + Description: description, + Paths: append(append([]string{}, paths...), config.FileName), + OwnedPaths: paths, + Quiet: quiet, + Warned: &warned, }); err != nil { return err } return cleanupRemote(ctx, git, options, m, nil, "staged changes") } - committed, err := git.CommitTreeWithTemporaryIndex(ctx, finalTree, removeCommitSubject(m)) + subject := fmt.Sprintf("Braid: Remove mirror '%s' from source '%s'", m.LocalPath, s.Name) + if removeSource { + subject = fmt.Sprintf("Braid: Remove source '%s'", s.Name) + } + committed, err := git.CommitTreeWithTemporaryIndex(ctx, finalTree, subject) if err != nil { return err } @@ -103,17 +129,13 @@ func (h RemoveHandler) remove(ctx context.Context, repo RepoContext, git RemoveG return errors.New("remove produced no commit") } - if err := git.RestorePathspecsFromHead(ctx, m.Path, config.FileName); err != nil { + if err := git.RestorePathspecsFromHead(ctx, append(paths, config.FileName)...); err != nil { return cleanupRemote(ctx, git, options, m, err, "") } return cleanupRemote(ctx, git, options, m, nil, "committed") } -func removeCommitSubject(m mirror.Mirror) string { - return fmt.Sprintf("Braid: Remove mirror '%s'", m.Path) -} - -func cleanupRemote(ctx context.Context, git RemoveGit, options cli.RemoveOptions, m mirror.Mirror, cause error, completed string) error { +func cleanupRemote(ctx context.Context, git RemoveGit, options cli.RemoveOptions, m source.SourceMirror, cause error, completed string) error { if options.Keep { return cause } diff --git a/internal/command/remove_test.go b/internal/command/remove_test.go index 98fc449..a8935bd 100644 --- a/internal/command/remove_test.go +++ b/internal/command/remove_test.go @@ -21,7 +21,8 @@ func TestRemoveCommandDeletesContentConfigAndRemote(t *testing.T) { repo := initDownstream(t) runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) - remote := loadMirror(t, repo, "vendor/basic").Remote() + loaded := loadMirror(t, repo, "vendor/basic") + remote := loaded.Remote() testutil.Git(t, repo, "remote", "add", remote, upstream) writeFailingPreCommitHook(t, repo) @@ -29,7 +30,7 @@ func TestRemoveCommandDeletesContentConfigAndRemote(t *testing.T) { assertPathMissing(t, repo, "vendor/basic") assertMirrorMissing(t, repo, "vendor/basic") assertNoRemote(t, repo, remote) - assertCommitSubject(t, repo, "Braid: Remove mirror 'vendor/basic'") + assertCommitSubject(t, repo, "Braid: Remove source '"+loaded.Name+"'") assertClean(t, repo) } @@ -86,7 +87,7 @@ func TestRemoveCommandNoCommitStagesDeletionAndPreservesUnrelatedState(t *testin stdout, _ := runCommandOKWithOutput(t, repo, []string{"remove", "vendor/basic", "--no-commit"}) - assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged removal of mirror 'vendor/basic'") + assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged removal of source ':001'") if got := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } @@ -142,7 +143,7 @@ func TestRemoveCommandNoCommitKeepKeepsRemoteOnly(t *testing.T) { } } -func TestRemoveCommandNoCommitQuietSuppressesSuccess(t *testing.T) { +func TestRemoveCommandNoCommitQuietPreservesResult(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") testutil.CommitAll(t, upstream, "base") @@ -151,8 +152,8 @@ func TestRemoveCommandNoCommitQuietSuppressesSuccess(t *testing.T) { stdout, stderr := runCommandOKWithOutput(t, repo, []string{"--quiet", "remove", "vendor/basic", "--no-commit"}) - if stdout != "" || stderr != "" { - t.Fatalf("stdout/stderr = %q / %q, want quiet no-commit output empty", stdout, stderr) + if stdout != "Braid: staged removal of source ':001'\n" || stderr != "" { + t.Fatalf("stdout/stderr = %q / %q, want quiet staged result", stdout, stderr) } } @@ -169,7 +170,7 @@ func TestRemoveCommandPathVariants(t *testing.T) { t.Helper() testutil.WriteFile(t, upstream, "lib/component.txt", "component\n") }, - addArgs: func(upstream string) []string { return []string{"add", upstream, "vendor/lib", "--path", "lib"} }, + addArgs: func(upstream string) []string { return []string{"add", upstream, "vendor/lib=lib"} }, localPath: "vendor/lib", }, { @@ -188,7 +189,7 @@ func TestRemoveCommandPathVariants(t *testing.T) { testutil.WriteFile(t, upstream, "LICENSE.txt", "license\n") }, addArgs: func(upstream string) []string { - return []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"} + return []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"} }, localPath: "licenses/THIRD_PARTY.txt", }, @@ -317,7 +318,7 @@ func TestRemoveCommandReportsPostCommitRemoteInspectFailure(t *testing.T) { git := &fakeRemoveGit{remoteURLErr: errors.New("inspect failed")} err := RemoveHandler{Options: Options{WorkDir: repo, ConfigRoot: repo}}.remove(context.Background(), testRepoContext(repo, git), git, cli.RemoveOptions{LocalPath: "vendor/basic"}, false, new(strings.Builder)) - if err == nil || !strings.Contains(err.Error(), `remove committed but failed to inspect Braid remote "main_braid_vendor_basic"`) || !strings.Contains(err.Error(), "inspect failed") { + if err == nil || !strings.Contains(err.Error(), `remove committed but failed to inspect Braid remote "main_braid_upstream"`) || !strings.Contains(err.Error(), "inspect failed") { t.Fatalf("remove error = %v, want post-commit remote inspect failure", err) } if !git.committed { @@ -331,7 +332,7 @@ func TestRemoveCommandReportsPostCommitRemoteCleanupFailure(t *testing.T) { git := &fakeRemoveGit{remoteExists: true, remoteRemoveErr: errors.New("remove remote failed")} err := RemoveHandler{Options: Options{WorkDir: repo, ConfigRoot: repo}}.remove(context.Background(), testRepoContext(repo, git), git, cli.RemoveOptions{LocalPath: "vendor/basic"}, false, new(strings.Builder)) - if err == nil || !strings.Contains(err.Error(), `remove committed but failed to remove Braid remote "main_braid_vendor_basic"`) || !strings.Contains(err.Error(), "remove remote failed") { + if err == nil || !strings.Contains(err.Error(), `remove committed but failed to remove Braid remote "main_braid_upstream"`) || !strings.Contains(err.Error(), "remove remote failed") { t.Fatalf("remove error = %v, want post-commit remote cleanup failure", err) } if !git.committed { @@ -345,7 +346,7 @@ func TestRemoveCommandNoCommitReportsPostStageRemoteCleanupFailure(t *testing.T) git := &fakeRemoveGit{remoteExists: true, remoteRemoveErr: errors.New("remove remote failed")} err := RemoveHandler{Options: Options{WorkDir: repo, ConfigRoot: repo}}.remove(context.Background(), testRepoContext(repo, git), git, cli.RemoveOptions{LocalPath: "vendor/basic", NoCommit: true}, false, new(strings.Builder)) - if err == nil || !strings.Contains(err.Error(), `remove staged changes but failed to remove Braid remote "main_braid_vendor_basic"`) || !strings.Contains(err.Error(), "remove remote failed") { + if err == nil || !strings.Contains(err.Error(), `remove staged changes but failed to remove Braid remote "main_braid_upstream"`) || !strings.Contains(err.Error(), "remove remote failed") { t.Fatalf("remove error = %v, want post-stage remote cleanup failure", err) } if git.committed { @@ -371,11 +372,12 @@ func writeRemoveMirrorConfig(t *testing.T, repo string) { t.Helper() data := []byte(`{ "config_version": 2, - "mirrors": { - "vendor/basic": { + "sources": { + "upstream": { "url": "https://example.invalid/upstream.git", "branch": "main", - "revision": "abcdef1234567890" + "revision": "abcdef1234567890", + "mirrors": {"vendor/basic": ""} } } } @@ -399,7 +401,7 @@ func assertMirrorMissing(t *testing.T, repo, localPath string) { if err != nil { t.Fatalf("Load config: %v", err) } - if _, ok := cfg.Get(localPath); ok { + if _, _, ok := cfg.MirrorByLocalPath(localPath); ok { t.Fatalf("mirror %q still exists in config", localPath) } } diff --git a/internal/command/status.go b/internal/command/status.go index 420b78c..9c7163c 100644 --- a/internal/command/status.go +++ b/internal/command/status.go @@ -9,7 +9,7 @@ import ( "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type StatusHandler struct { @@ -38,19 +38,16 @@ func (h StatusHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { } if inv.Status.LocalPath != "" { - localPath, err := normalizeLocalPath(repo, inv.Status.LocalPath) + selection, err := resolveSourceSelection(repo, cfg, inv.Status.LocalPath, true) if err != nil { return err } - m, err := cfg.GetRequired(localPath) - if err != nil { - return err - } - return h.statusOne(ctx, git, cache, m, inv.Global.Verbose, progress, stdout, stderr) + return h.statusSource(ctx, git, cache, selection, inv.Global.Verbose, progress, stdout, stderr) } - for _, localPath := range cfg.Paths() { - if err := h.statusOne(ctx, git, cache, cfg.Mirrors[localPath], inv.Global.Verbose, progress, stdout, stderr); err != nil { + for _, s := range cfg.SourcesSorted() { + selection := source.SourceSelection{Source: s, Mirrors: s.SortedMirrors()} + if err := h.statusSource(ctx, git, cache, selection, inv.Global.Verbose, progress, stdout, stderr); err != nil { return err } } @@ -67,7 +64,8 @@ func (h StatusHandler) statusGit(repo RepoContext, inv cli.Invocation, trace io. return gitexec.New(repo.GitWorkTreeRoot, inv.Global.Verbose, trace) } -func (h StatusHandler) statusOne(ctx context.Context, git StatusGit, cache CacheConfig, m mirror.Mirror, verbose bool, progress progressReporter, stdout, trace io.Writer) (err error) { +func (h StatusHandler) statusSource(ctx context.Context, git StatusGit, cache CacheConfig, selection source.SourceSelection, verbose bool, progress progressReporter, stdout, trace io.Writer) (err error) { + m := selection.Source.WithMirror(selection.Mirrors[0]) if cache.Enabled { if err := fetchCache(ctx, cache, m, verbose, progress, trace); err != nil { return err @@ -94,52 +92,92 @@ func (h StatusHandler) statusOne(ctx context.Context, git StatusGit, cache Cache if err != nil { return err } - - states := []string{} - if newRevision != baseRevision { - states = append(states, "Remote Modified") + for _, mirror := range selection.Mirrors { + if err := h.statusOne(ctx, git, selection.Source.WithMirror(mirror), baseRevision, newRevision, stdout); err != nil { + return err + } } + return nil +} - files, err := git.LsFiles(ctx, m.Path) +func (h StatusHandler) statusOne(ctx context.Context, git StatusGit, m source.SourceMirror, baseRevision, newRevision string, stdout io.Writer) error { + baseItem, basePresent, err := optionalItemAtRevision(ctx, git, m, baseRevision) if err != nil { return err } - if strings.TrimSpace(files) == "" { - states = append(states, "Removed Locally") - } else { - modified, err := locallyModified(ctx, git, m) - if err != nil { - return err - } - if modified { - states = append(states, "Locally Modified") + latestItem, latestPresent, err := optionalItemAtRevision(ctx, git, m, newRevision) + if err != nil { + return err + } + localItem, localErr := git.LsTreeItem(ctx, "HEAD", m.LocalPath) + localPresent := true + if gitexec.IsTreeItemNotFound(localErr) { + localPresent = false + localErr = nil + } + if localErr != nil { + return localErr + } + modified, err := locallyModified(ctx, git, m) + if err != nil { + return err + } + contentState := mirrorContentState(baseItem, basePresent, localItem, localPresent, latestItem, latestPresent) + if modified && localPresent { + if contentState == "Modified Remotely" || contentState == "Removed Remotely" { + contentState = "Modified Locally And Remotely" + } else { + contentState = "Modified Locally" } } + sourceState := "Current" + if m.Locked() { + sourceState = "Locked" + } else if newRevision != baseRevision { + sourceState = "Behind" + } - if _, err := fmt.Fprintf(stdout, "%s (%s) [%s]", m.Path, baseRevision, trackingLabel(m)); err != nil { + if _, err := fmt.Fprintf(stdout, "%s (%s) [%s]", m.LocalPath, baseRevision, trackingLabel(m)); err != nil { return err } - for _, state := range states { - if _, err := fmt.Fprintf(stdout, " (%s)", state); err != nil { - return err + _, err = fmt.Fprintf(stdout, " (%s, %s)\n", contentState, sourceState) + return err +} + +func mirrorContentState(base gitexec.TreeItem, basePresent bool, local gitexec.TreeItem, localPresent bool, latest gitexec.TreeItem, latestPresent bool) string { + equal := func(a gitexec.TreeItem, ap bool, b gitexec.TreeItem, bp bool) bool { + return ap == bp && (!ap || sameTreeItem(a, b)) + } + if equal(local, localPresent, latest, latestPresent) { + return "Up To Date" + } + if equal(local, localPresent, base, basePresent) { + if !latestPresent { + return "Removed Remotely" } + return "Modified Remotely" } - _, err = fmt.Fprintln(stdout) - return err + if equal(latest, latestPresent, base, basePresent) { + if !localPresent { + return "Removed Locally" + } + return "Modified Locally" + } + return "Modified Locally And Remotely" } -func trackingLabel(m mirror.Mirror) string { +func trackingLabel(m source.SourceMirror) string { switch { case m.Locked(): return "REVISION LOCKED" - case m.Tag != "": - return "TAG=" + m.Tag + case m.Tag() != "": + return "TAG=" + m.Tag() default: - return "BRANCH=" + m.Branch + return "BRANCH=" + m.Branch() } } -func locallyModified(ctx context.Context, git StatusGit, m mirror.Mirror) (bool, error) { +func locallyModified(ctx context.Context, git StatusGit, m source.SourceMirror) (bool, error) { args, err := buildDiffArgs(ctx, git, m, nil) if err != nil { return false, err diff --git a/internal/command/status_test.go b/internal/command/status_test.go index 3493950..e72c33b 100644 --- a/internal/command/status_test.go +++ b/internal/command/status_test.go @@ -33,8 +33,8 @@ func TestStatusCommandStates(t *testing.T) { testutil.WriteFile(t, upstream, "README.md", "remote changed\n") testutil.CommitAll(t, upstream, "remote changed") out := runCommandOK(t, repo, []string{"status", "vendor/basic"}) - assertContains(t, out, "(Remote Modified)") - assertNotContains(t, out, "(Locally Modified)") + assertContains(t, out, "Modified Remotely") + assertNotContains(t, out, "Modified Locally") }) t.Run("locally modified", func(t *testing.T) { @@ -47,8 +47,8 @@ func TestStatusCommandStates(t *testing.T) { testutil.WriteFile(t, repo, "vendor/basic/README.md", "local changed\n") testutil.CommitAll(t, repo, "local change") out := runCommandOK(t, repo, []string{"status", "vendor/basic"}) - assertContains(t, out, "(Locally Modified)") - assertNotContains(t, out, "(Remote Modified)") + assertContains(t, out, "Modified Locally") + assertNotContains(t, out, "Modified Remotely") }) t.Run("removed locally", func(t *testing.T) { @@ -61,8 +61,8 @@ func TestStatusCommandStates(t *testing.T) { testutil.Git(t, repo, "rm", "-r", "vendor/basic") testutil.Git(t, repo, "commit", "-m", "remove mirror content") out := runCommandOK(t, repo, []string{"status", "vendor/basic"}) - assertContains(t, out, "(Removed Locally)") - assertNotContains(t, out, "(Locally Modified)") + assertContains(t, out, "Removed Locally") + assertNotContains(t, out, "Modified Locally") }) } @@ -123,7 +123,7 @@ func TestStatusCommandMirrorModes(t *testing.T) { testutil.WriteFile(t, upstream, "lay outs/layout.txt", "layout\n") testutil.CommitAll(t, upstream, "spaces") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "vendor/path with spaces", "--path", "lay outs"}) + runCommandOK(t, repo, []string{"add", upstream, "vendor/path with spaces=lay outs"}) out := runCommandOK(t, repo, []string{"status", "vendor/path with spaces"}) if !strings.HasPrefix(out, "vendor/path with spaces (") { diff --git a/internal/command/sync.go b/internal/command/sync.go index af913fa..6eae394 100644 --- a/internal/command/sync.go +++ b/internal/command/sync.go @@ -4,12 +4,13 @@ import ( "context" "fmt" "io" + "sort" "strings" "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type SyncHandler struct { @@ -18,7 +19,7 @@ type SyncHandler struct { type syncTarget struct { LocalPath string - Mirror mirror.Mirror + Mirror source.SourceMirror } type syncPushAction struct { @@ -81,6 +82,7 @@ func (h SyncHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { } var runErr error var updateConflict bool + var pushedSources []string if !inv.Sync.PullOnly { if err := h.hydrateMissingRecordedRevisions(ctx, git, cache, targets, inv.Sync.Keep, inv.Global.Verbose, progress, stderr); err != nil { runErr = err @@ -89,8 +91,11 @@ func (h SyncHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { plan, err := h.buildPushPlan(ctx, git, cache, targets, inv.Sync.Keep, inv.Global.Verbose, progress, stderr) if err != nil { runErr = err - } else if err := h.runPushPlan(ctx, repo, git, plan, inv, stdout, stderr); err != nil { - runErr = err + } else { + pushedSources, err = h.runPushPlan(ctx, repo, git, plan, inv, stdout, stderr) + if err != nil { + runErr = err + } } } } @@ -100,17 +105,23 @@ func (h SyncHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { updateOptions := cli.UpdateOptions{Keep: inv.Sync.Keep} for _, target := range targets { result, err := update.updateOne(ctx, repo, git, processGit, cache, target.LocalPath, updateOptions, inv.Global.Verbose, inv.Global.Quiet, progress, stdout, stderr) - if result.Status == updateStatusConflict && autostashOK { - updateConflict = true + if result.Status == updateStatusConflict { + updateConflict = autostashOK if err != nil { runErr = fmt.Errorf("pull %s: %w", target.LocalPath, err) } else { runErr = fmt.Errorf("pull %s reached conflict state", target.LocalPath) } + if len(pushedSources) != 0 { + runErr = syncPostPushPullFailure(runErr, pushedSources) + } break } if err != nil { runErr = fmt.Errorf("pull %s: %w", target.LocalPath, err) + if len(pushedSources) != 0 { + runErr = syncPostPushPullFailure(runErr, pushedSources) + } break } } @@ -124,6 +135,9 @@ func (h SyncHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if runErr != nil { return fmt.Errorf("%w; additionally, %w", runErr, restoreErr) } + if len(pushedSources) != 0 { + return syncPostPushPullFailure(restoreErr, pushedSources) + } return restoreErr } } @@ -145,15 +159,15 @@ func (h SyncHandler) syncGit(repo RepoContext, inv cli.Invocation, trace io.Writ func (h SyncHandler) syncTargets(repo RepoContext, cfg config.Config, localPaths []string) ([]syncTarget, []string, error) { if len(localPaths) == 0 { - targets := make([]syncTarget, 0, len(cfg.Mirrors)) + targets := make([]syncTarget, 0, len(cfg.Sources)) var skippedLocked []string - for _, localPath := range cfg.Paths() { - m := cfg.Mirrors[localPath] - if m.Locked() { - skippedLocked = append(skippedLocked, localPath) + for _, s := range cfg.SourcesSorted() { + if s.Locked() { + skippedLocked = append(skippedLocked, ":"+s.Name) continue } - targets = append(targets, syncTarget{LocalPath: localPath, Mirror: m}) + mirror := s.SortedMirrors()[0] + targets = append(targets, syncTarget{LocalPath: mirror.LocalPath, Mirror: s.WithMirror(mirror)}) } return targets, skippedLocked, nil } @@ -161,30 +175,30 @@ func (h SyncHandler) syncTargets(repo RepoContext, cfg config.Config, localPaths targets := make([]syncTarget, 0, len(localPaths)) seen := map[string]struct{}{} for _, rawPath := range localPaths { - localPath, err := normalizeLocalPath(repo, rawPath) + selection, err := resolveSourceSelection(repo, cfg, rawPath, false) if err != nil { return nil, nil, err } - if _, ok := seen[localPath]; ok { - return nil, nil, fmt.Errorf("duplicate sync path: %s", localPath) - } - seen[localPath] = struct{}{} - m, err := cfg.GetRequired(localPath) - if err != nil { - return nil, nil, err + if _, ok := seen[selection.Source.Name]; ok { + continue } - targets = append(targets, syncTarget{LocalPath: localPath, Mirror: m}) + seen[selection.Source.Name] = struct{}{} + mirror := selection.Mirrors[0] + targets = append(targets, syncTarget{LocalPath: mirror.LocalPath, Mirror: selection.Source.WithMirror(mirror)}) } + sort.Slice(targets, func(i, j int) bool { return targets[i].Mirror.Name < targets[j].Mirror.Name }) return targets, nil, nil } func (h SyncHandler) prepareSyncAutostash(ctx context.Context, repo RepoContext, git syncAutostashGit, targets []syncTarget, autostashEnabled bool) (syncAutostash, bool, error) { paths := make([]string, 0, len(targets)) for _, target := range targets { - if mirrorOverlapsConfig(target.Mirror.Path) { - return syncAutostash{}, false, fmt.Errorf("mirror path %q overlaps %s", target.Mirror.Path, config.FileName) + for _, path := range target.Mirror.LocalPaths() { + if mirrorOverlapsConfig(path) { + return syncAutostash{}, false, fmt.Errorf("mirror path %q overlaps %s", path, config.FileName) + } + paths = append(paths, path) } - paths = append(paths, target.Mirror.Path) } if state, blocked, err := git.BlockingOperation(ctx); err != nil { return syncAutostash{}, false, err @@ -293,7 +307,7 @@ func (h SyncHandler) buildPushPlan(ctx context.Context, git PushGit, cache Cache if !changed { continue } - if target.Mirror.Branch == "" { + if target.Mirror.Branch() == "" { return syncPushPlan{}, syncNonBranchLocalChangeError(target.LocalPath) } actions = append(actions, syncPushAction{Target: target, BaseRevision: baseRevision}) @@ -316,53 +330,100 @@ func (h SyncHandler) buildPushPlan(ctx context.Context, git PushGit, cache Cache return syncPushPlan{Actions: actions}, nil } -func (h SyncHandler) withFetchedMirrorForPlanning(ctx context.Context, git PushGit, cache CacheConfig, m mirror.Mirror, keep bool, verbose bool, progress progressReporter, trace io.Writer, fn func() error) (err error) { +func (h SyncHandler) withFetchedMirrorForPlanning(ctx context.Context, git PushGit, cache CacheConfig, m source.SourceMirror, keep bool, verbose bool, progress progressReporter, trace io.Writer, fn func() error) (err error) { if cache.Enabled { if err := fetchCache(ctx, cache, m, verbose, progress, trace); err != nil { return err } } + previousURL, previousExists, err := git.RemoteURL(ctx, m.Remote()) + if err != nil { + return err + } + var previousConfig gitexec.RemoteConfigSnapshot + if previousExists { + if exact, ok := git.(exactRemoteConfigGit); ok { + previousConfig, err = exact.SnapshotRemoteConfig(ctx, m.Remote()) + if err != nil { + return err + } + } + } if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return err } - if !keep { - defer func() { - removeErr := git.RemoteRemove(ctx, m.Remote()) - if err == nil { + defer func() { + if keep && err == nil { + return + } + if _, ok, inspectErr := git.RemoteURL(ctx, m.Remote()); inspectErr == nil && ok { + if removeErr := git.RemoteRemove(ctx, m.Remote()); removeErr != nil && err == nil { err = removeErr } - }() - } + } else if inspectErr != nil && err == nil { + err = inspectErr + } + if err != nil && previousExists { + if exact, ok := git.(exactRemoteConfigGit); ok && previousConfig != nil { + if restoreErr := exact.RestoreRemoteConfig(ctx, m.Remote(), previousConfig); restoreErr != nil { + err = fmt.Errorf("%w; failed to restore existing remote: %w", err, restoreErr) + } + } else if restoreErr := git.RemoteAdd(ctx, m.Remote(), previousURL); restoreErr != nil { + err = fmt.Errorf("%w; failed to restore existing remote: %w", err, restoreErr) + } + } + }() if err := fetchMirror(ctx, git, cache, m, progress); err != nil { return err } return fn() } -func (h SyncHandler) runPushPlan(ctx context.Context, repo RepoContext, git PushGit, plan syncPushPlan, inv cli.Invocation, stdout, stderr io.Writer) error { +func (h SyncHandler) runPushPlan(ctx context.Context, repo RepoContext, git PushGit, plan syncPushPlan, inv cli.Invocation, stdout, stderr io.Writer) ([]string, error) { push := PushHandler(h) + var completed []string for _, action := range plan.Actions { - result, err := push.push(ctx, repo, git, action.Target.Mirror, action.Target.Mirror.Branch, inv.Sync.Keep, "", inv.Global, stdout, stderr) + result, err := push.push(ctx, repo, git, action.Target.Mirror, action.Target.Mirror.Branch(), inv.Sync.Keep, "", inv.Global, stdout, stderr) if err != nil { - return err + if result.Status == pushStatusPushed { + completed = append(completed, ":"+action.Target.Mirror.Name) + } + return completed, syncPushFailure(err, completed) } if result.Status == pushStatusNotUpToDate { - return syncNotUpToDateError(action.Target.LocalPath) + return completed, syncPushFailure(syncNotUpToDateError(action.Target.LocalPath), completed) } + completed = append(completed, ":"+action.Target.Mirror.Name) } - return nil + return completed, nil } -func committedLocalMirrorChange(ctx context.Context, git PushGit, m mirror.Mirror) (bool, string, error) { - localItem, err := git.LsTreeItem(ctx, "HEAD", m.Path) - if err != nil { - return false, "", err +func syncPushFailure(cause error, completed []string) error { + if len(completed) == 0 { + return cause + } + return fmt.Errorf("%w; sync already pushed %s and cannot roll those upstream updates back; after resolving the failure, run braid sync %s", cause, strings.Join(completed, ", "), strings.Join(completed, " ")) +} + +func syncPostPushPullFailure(cause error, completed []string) error { + commands := make([]string, 0, len(completed)) + for _, name := range completed { + commands = append(commands, "braid pull "+name) } + return fmt.Errorf("%w; sync already pushed %s and cannot roll those upstream updates back; after resolving the failure, run %s", cause, strings.Join(completed, ", "), strings.Join(commands, " and ")) +} + +func committedLocalMirrorChange(ctx context.Context, git PushGit, m source.SourceMirror) (bool, string, error) { baseRevision, err := git.RevParse(ctx, m.Revision+"^{commit}") if err != nil { return false, "", err } - newTree, err := git.MakeTreeWithItemIn(ctx, baseRevision, m.RemotePath, localItem) + for _, mirror := range m.Mirrors { + if _, err := git.LsTreeItem(ctx, "HEAD", mirror.LocalPath); err != nil { + return false, "", err + } + } + newTree, err := reconstructUpstreamTree(ctx, git, m) if err != nil { return false, "", err } diff --git a/internal/command/sync_test.go b/internal/command/sync_test.go index 6a61773..63920b9 100644 --- a/internal/command/sync_test.go +++ b/internal/command/sync_test.go @@ -11,7 +11,6 @@ import ( "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" "braid/internal/testutil" ) @@ -44,8 +43,8 @@ func TestSyncCommandPushesChangedBranchThenUpdates(t *testing.T) { if got := loadMirror(t, repo, "vendor/basic").Revision; got != pushedRevision { t.Fatalf("synced revision = %q, want %q", got, pushedRevision) } - assertCommitSubject(t, repo, "Braid: Update mirror 'vendor/basic' to '"+pushedRevision[:7]+"'") - assertNoGitRemote(t, repo, "main_braid_vendor_basic") + assertCommitSubject(t, repo, "Braid: Update source '001' to '"+pushedRevision[:7]+"'") + assertNoGitRemote(t, repo, "main_braid_001") } func TestSyncCommandProvenanceGuidanceIsPerPushedMirror(t *testing.T) { @@ -107,7 +106,7 @@ func TestSyncCommandGeneratedMessagesArePerPushedMirror(t *testing.T) { testutil.CommitAll(t, repo, "local a generated") testutil.WriteFile(t, repo, "vendor/b/README.md", "b local\n") testutil.CommitAll(t, repo, "local b generated") - generator := writeGenerator(t, "#!/bin/sh\nprompt=$1\nmessage=$2\nif grep -q 'Local mirror path: vendor/a' \"$prompt\"; then\n printf 'Generated for vendor/a\\n' > \"$message\"\nelif grep -q 'Local mirror path: vendor/b' \"$prompt\"; then\n printf 'Generated for vendor/b\\n' > \"$message\"\nelse\n exit 18\nfi\n") + generator := writeGenerator(t, "#!/bin/sh\nprompt=$1\nmessage=$2\nif grep -q 'Source name: 001' \"$prompt\"; then\n printf 'Generated for vendor/a\\n' > \"$message\"\nelif grep -q 'Source name: 002' \"$prompt\"; then\n printf 'Generated for vendor/b\\n' > \"$message\"\nelse\n exit 18\nfi\n") t.Setenv(pushMessageCommandEnv, shellQuote(generator)+" {PROMPT_FILE} {MESSAGE_FILE}") captureDir, editor := writeSequenceCapturingEditor(t, "Sync reviewed") t.Setenv("GIT_EDITOR", editor) @@ -196,8 +195,8 @@ func TestSyncCommandPullOnlyAllowsExplicitRevisionLockedMirror(t *testing.T) { t.Setenv("GIT_EDITOR", writeFailingEditor(t)) out := runCommandOK(t, repo, []string{"sync", "--pull-only", "vendor/revision"}) - if out != "" { - t.Fatalf("sync stdout = %q, want quiet output", out) + if out != "Braid: source :001 is already up to date\n" { + t.Fatalf("sync stdout = %q, want source no-op result", out) } if got := testutil.CurrentRevision(t, repo); got != head { @@ -233,7 +232,7 @@ func TestSyncCommandNoPathSelectsBranchAndTagMirrorsAndSkipsLocked(t *testing.T) testutil.Git(t, upstreamB, "tag", "-f", "v1") out := runCommandOK(t, repo, []string{"sync", "--pull-only"}) - if want := skippedLockedOutput("vendor/locked"); out != want { + if want := skippedLockedOutput("003"); out != want { t.Fatalf("sync stdout = %q, want %q", out, want) } @@ -249,8 +248,8 @@ func TestSyncCommandNoPathSelectsBranchAndTagMirrorsAndSkipsLocked(t *testing.T) assertFile(t, repo, "vendor/locked/README.md", "dirty locked\n") subjects := strings.Split(strings.TrimSpace(testutil.Git(t, repo, "log", "-2", "--pretty=%s").Stdout), "\n") - if len(subjects) != 2 || !strings.Contains(subjects[0], "vendor/b") || !strings.Contains(subjects[1], "vendor/a") { - t.Fatalf("last sync update subjects = %#v, want newest vendor/b then vendor/a", subjects) + if len(subjects) != 2 || !strings.Contains(subjects[0], "'002'") || !strings.Contains(subjects[1], "'001'") { + t.Fatalf("last sync update subjects = %#v, want newest source 002 then source 001", subjects) } } @@ -268,8 +267,8 @@ func TestSyncCommandNoPathQuietWhenNoLockedMirrors(t *testing.T) { runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) out := runCommandOK(t, repo, args) - if out != "" { - t.Fatalf("sync stdout = %q, want quiet output", out) + if out != "Braid: source :001 is already up to date\n" { + t.Fatalf("sync stdout = %q, want source no-op result", out) } }) } @@ -285,14 +284,14 @@ func TestSyncCommandAllLockedNoPathReportsSkippedMirrors(t *testing.T) { writeLockedMirrorConfig(t, repo, "vendor/a", "vendor/z") out := runCommandOK(t, repo, args) - if want := skippedLockedOutput("vendor/a", "vendor/z"); out != want { + if want := skippedLockedOutput("vendor-a", "vendor-z"); out != want { t.Fatalf("sync stdout = %q, want %q", out, want) } }) } } -func TestSyncCommandExplicitTargetsPreserveOrderAndRejectDuplicates(t *testing.T) { +func TestSyncCommandExplicitTargetsDeduplicateAndSortSources(t *testing.T) { upstreamA := testutil.InitRepo(t) testutil.WriteFile(t, upstreamA, "README.md", "a base\n") testutil.CommitAll(t, upstreamA, "a base") @@ -304,8 +303,7 @@ func TestSyncCommandExplicitTargetsPreserveOrderAndRejectDuplicates(t *testing.T runCommandOK(t, repo, []string{"add", upstreamA, "vendor/a"}) runCommandOK(t, repo, []string{"add", upstreamB, "vendor/b"}) - duplicateErr := runCommandError(t, repo, []string{"sync", "vendor/a", "./vendor/a"}) - assertContains(t, duplicateErr, "duplicate sync path: vendor/a") + runCommandOK(t, repo, []string{"sync", "vendor/a", "./vendor/a"}) missingErr := runCommandError(t, repo, []string{"sync", "vendor/missing"}) assertContains(t, missingErr, "mirror does not exist: vendor/missing") @@ -316,8 +314,8 @@ func TestSyncCommandExplicitTargetsPreserveOrderAndRejectDuplicates(t *testing.T runCommandOK(t, repo, []string{"sync", "--pull-only", "vendor/b", "vendor/a"}) subjects := strings.Split(strings.TrimSpace(testutil.Git(t, repo, "log", "-2", "--pretty=%s").Stdout), "\n") - if len(subjects) != 2 || !strings.Contains(subjects[0], "vendor/a") || !strings.Contains(subjects[1], "vendor/b") { - t.Fatalf("last sync update subjects = %#v, want explicit order vendor/b then vendor/a", subjects) + if len(subjects) != 2 || !strings.Contains(subjects[0], "'002'") || !strings.Contains(subjects[1], "'001'") { + t.Fatalf("last sync update subjects = %#v, want source-name order 001 then 002", subjects) } } @@ -328,7 +326,7 @@ func TestSyncCommandTargetValidationAndScopedPrecheckErrors(t *testing.T) { revision := testutil.CommitAll(t, upstream, "base") repo := testutil.InitRepo(t) cfg := config.Empty() - if err := cfg.Add(mirror.Mirror{Path: ".braids.json", URL: upstream, Branch: "main", Revision: revision}); err != nil { + if err := cfg.AddSource(testSourceMirror(".braids.json", "", upstream, "main", "", revision, false).Source); err != nil { t.Fatalf("add mirror config: %v", err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { @@ -485,11 +483,12 @@ func TestSyncCommandAutostashIgnoredOnlyState(t *testing.T) { testutil.WriteFile(t, upstream, "remote.txt", "remote\n") testutil.CommitAll(t, upstream, "remote") - runCommandOK(t, repo, []string{"sync", "--pull-only", "vendor/basic"}) + stderr := runCommandError(t, repo, []string{"sync", "--pull-only", "vendor/basic"}) + assertContains(t, stderr, "local changes are present in vendor/basic") assertFile(t, repo, "vendor/basic/ignored.log", "ignored\n") if stashList := strings.TrimSpace(testutil.Git(t, repo, "stash", "list").Stdout); stashList != "" { - t.Fatalf("stash list = %q, want no autostash without flag", stashList) + t.Fatalf("stash list = %q, want no autostash on blocked sync", stashList) } testutil.WriteFile(t, upstream, "another.txt", "another\n") @@ -509,7 +508,7 @@ func TestSyncCommandAutostashRestoresAfterOperationalFailure(t *testing.T) { repo := initDownstream(t) cfg := config.Empty() bogusRevision := strings.Repeat("0", 40) - if err := cfg.Add(mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: bogusRevision}); err != nil { + if err := cfg.AddSource(testSourceMirror("vendor/basic", "", upstream, "main", "", bogusRevision, false).Source); err != nil { t.Fatalf("add mirror config: %v", err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { @@ -522,7 +521,7 @@ func TestSyncCommandAutostashRestoresAfterOperationalFailure(t *testing.T) { stderr := runCommandError(t, repo, []string{"sync", "--autostash", "vendor/basic"}) - assertContains(t, stderr, "recorded revision "+bogusRevision+" for mirror vendor/basic is unavailable from upstream "+upstream+"; the repository-local cache may have been deleted or the upstream history may have been rewritten") + assertContains(t, stderr, "recorded revision "+bogusRevision+" for source :vendor-basic is unavailable from upstream "+upstream+"; the repository-local cache may have been deleted or the upstream history may have been rewritten") assertFile(t, repo, "vendor/basic/README.md", "dirty\n") if stashList := strings.TrimSpace(testutil.Git(t, repo, "stash", "list").Stdout); stashList != "" { t.Fatalf("stash list = %q, want autostash restored and dropped", stashList) @@ -545,7 +544,7 @@ func TestSyncCommandAutostashUpdateConflictLeavesStash(t *testing.T) { stdout, stderr := runCommandErrorWithOutput(t, repo, []string{"sync", "--pull-only", "--autostash", "vendor/basic"}) - assertContains(t, stdout, "CONFLICT: vendor/basic/README.md") + assertContains(t, stdout, " vendor/basic/README.md") assertContains(t, stderr, "Braid preserved autostash") assertContains(t, stderr, "Resolve the Braid pull conflict first") assertContains(t, stderr, "git stash apply") @@ -562,7 +561,7 @@ func TestSyncCommandAutostashUpdateConflictLeavesStash(t *testing.T) { assertContains(t, string(data), "remote committed") } -func TestSyncCommandAutostashUpdateConflictWriteFailureLeavesStash(t *testing.T) { +func TestSyncCommandAutostashUpdateConflictWriteFailureRollsBackAndRestores(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") testutil.CommitAll(t, upstream, "base") @@ -581,21 +580,22 @@ func TestSyncCommandAutostashUpdateConflictWriteFailureLeavesStash(t *testing.T) stdout, stderr := runCommandErrorWithOutput(t, repo, []string{"sync", "--pull-only", "--autostash", "vendor/basic"}) - assertContains(t, stdout, "CONFLICT: vendor/basic/README.md") + if stdout != "" { + t.Fatalf("stdout = %q, want rollback before conflict output", stdout) + } assertContains(t, stderr, "pull vendor/basic:") assertContains(t, stderr, "MERGE_MSG") - assertContains(t, stderr, "Braid preserved autostash") - assertContains(t, stderr, "Resolve the Braid pull conflict first") - assertNoFile(t, repo, "vendor/basic/note.txt") + assertNotContains(t, stderr, "Braid preserved autostash") + assertFile(t, repo, "vendor/basic/note.txt", "saved work\n") stashList := testutil.Git(t, repo, "stash", "list").Stdout - assertContains(t, stashList, "braid sync autostash") + assertNotContains(t, stashList, "braid sync autostash") data, err := os.ReadFile(filepath.Join(repo, "vendor", "basic", "README.md")) if err != nil { t.Fatalf("read conflicted README: %v", err) } - assertContains(t, string(data), "<<<<<<<") - assertContains(t, string(data), "local committed") - assertContains(t, string(data), "remote committed") + if string(data) != "local committed\n" { + t.Fatalf("README after rollback = %q", data) + } } func TestSyncCommandAutostashRestoreReportsCleanupFailureAfterApply(t *testing.T) { @@ -679,7 +679,7 @@ func TestSyncCommandScopedPrecheckRunsBeforeSideEffects(t *testing.T) { if got := testutil.CurrentRevision(t, upstreamA); got != aBase { t.Fatalf("upstream a revision = %q, want unchanged %q", got, aBase) } - assertNoGitRemote(t, repo, "main_braid_vendor_a") + assertNoGitRemote(t, repo, "main_braid_001") } func TestSyncCommandNoPathScopedPrecheckRunsBeforeSideEffects(t *testing.T) { @@ -708,7 +708,7 @@ func TestSyncCommandNoPathScopedPrecheckRunsBeforeSideEffects(t *testing.T) { if got := loadMirror(t, repo, "vendor/b").Revision; got != bBase { t.Fatalf("vendor/b revision = %q, want unchanged %q", got, bBase) } - assertNoGitRemote(t, repo, "main_braid_vendor_a") + assertNoGitRemote(t, repo, "main_braid_001") } func TestSyncCommandNoPathSuppressesSkippedMirrorOutputOnError(t *testing.T) { @@ -946,7 +946,7 @@ func TestSyncCommandRejectsDeletedSelectedMirrorPath(t *testing.T) { { name: "single file", addArgs: func(upstream string) []string { - return []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"} + return []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"} }, localPath: "licenses/THIRD_PARTY.txt", remove: "licenses/THIRD_PARTY.txt", @@ -982,7 +982,7 @@ func TestSyncCommandRemotePathAwareClassification(t *testing.T) { testutil.CommitAll(t, upstream, "base") testutil.Git(t, upstream, "config", "receive.denyCurrentBranch", "updateInstead") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "vendor/lib", "--path", "lib"}) + runCommandOK(t, repo, []string{"add", upstream, "vendor/lib=lib"}) testutil.WriteFile(t, repo, "vendor/lib/component.txt", "local\n") testutil.CommitAll(t, repo, "local subdir") t.Setenv("GIT_EDITOR", writeEditor(t, "Sync subdir")) @@ -999,7 +999,7 @@ func TestSyncCommandRemotePathAwareClassification(t *testing.T) { testutil.WriteFile(t, upstream, "outside.txt", "outside\n") testutil.CommitAll(t, upstream, "base") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "vendor/lib", "--path", "lib"}) + runCommandOK(t, repo, []string{"add", upstream, "vendor/lib=lib"}) testutil.WriteFile(t, upstream, "outside.txt", "remote outside\n") next := testutil.CommitAll(t, upstream, "remote outside") t.Setenv("GIT_EDITOR", writeFailingEditor(t)) @@ -1019,7 +1019,7 @@ func TestSyncCommandRemotePathAwareClassification(t *testing.T) { testutil.CommitAll(t, upstream, "base") testutil.Git(t, upstream, "config", "receive.denyCurrentBranch", "updateInstead") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"}) + runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"}) testutil.WriteFile(t, repo, "licenses/THIRD_PARTY.txt", "local license\n") testutil.CommitAll(t, repo, "local license") t.Setenv("GIT_EDITOR", writeEditor(t, "Sync license")) @@ -1036,7 +1036,7 @@ func TestSyncCommandRemotePathAwareClassification(t *testing.T) { testutil.WriteFile(t, upstream, "README.md", "readme\n") testutil.CommitAll(t, upstream, "base") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"}) + runCommandOK(t, repo, []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"}) testutil.WriteFile(t, upstream, "README.md", "remote readme\n") next := testutil.CommitAll(t, upstream, "remote readme") t.Setenv("GIT_EDITOR", writeFailingEditor(t)) @@ -1079,7 +1079,7 @@ func TestSyncCommandReportsUnavailableRecordedRevisionAfterHydration(t *testing. repo := initDownstream(t) cfg := config.Empty() bogusRevision := strings.Repeat("0", 40) - if err := cfg.Add(mirror.Mirror{Path: "vendor/basic", URL: upstream, Branch: "main", Revision: bogusRevision}); err != nil { + if err := cfg.AddSource(testSourceMirror("vendor/basic", "", upstream, "main", "", bogusRevision, false).Source); err != nil { t.Fatalf("add mirror config: %v", err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { @@ -1091,7 +1091,7 @@ func TestSyncCommandReportsUnavailableRecordedRevisionAfterHydration(t *testing. stderr := runCommandError(t, repo, []string{"sync", "vendor/basic"}) - assertContains(t, stderr, "recorded revision "+bogusRevision+" for mirror vendor/basic is unavailable from upstream "+upstream+"; the repository-local cache may have been deleted or the upstream history may have been rewritten") + assertContains(t, stderr, "recorded revision "+bogusRevision+" for source :vendor-basic is unavailable from upstream "+upstream+"; the repository-local cache may have been deleted or the upstream history may have been rewritten") } func TestSyncCommandKeepRetainsTemporaryRemote(t *testing.T) { @@ -1103,7 +1103,7 @@ func TestSyncCommandKeepRetainsTemporaryRemote(t *testing.T) { runCommandOK(t, repo, []string{"sync", "--pull-only", "--keep", "vendor/basic"}) - assertGitRemote(t, repo, "main_braid_vendor_basic") + assertGitRemote(t, repo, "main_braid_001") } func TestSyncCommandKeepRetainsDistinctTagTrackingRefs(t *testing.T) { @@ -1123,8 +1123,8 @@ func TestSyncCommandKeepRetainsDistinctTagTrackingRefs(t *testing.T) { runCommandOK(t, repo, []string{"sync", "--pull-only", "--keep", "vendor/a", "vendor/b"}) git := gitexec.New(repo, false, nil) - assertRefCommit(t, ctx, git, "refs/remotes/v1_braid_vendor_a/tags/v1", revisionA) - assertRefCommit(t, ctx, git, "refs/remotes/v1_braid_vendor_b/tags/v1", revisionB) + assertRefCommit(t, ctx, git, "refs/remotes/v1_braid_001/tags/v1", revisionA) + assertRefCommit(t, ctx, git, "refs/remotes/v1_braid_002/tags/v1", revisionB) assertRefMissing(t, ctx, git, "refs/tags/v1") } diff --git a/internal/command/test_support_test.go b/internal/command/test_support_test.go index ba2d948..2518e18 100644 --- a/internal/command/test_support_test.go +++ b/internal/command/test_support_test.go @@ -12,7 +12,7 @@ import ( "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" "braid/internal/testutil" ) @@ -112,6 +112,19 @@ func runCommandErrorInDir(t *testing.T, repo, dir string, args []string) string return stderr.String() } +func runCommandErrorInDirWithOptions(t *testing.T, repo, dir string, options Options, args []string) string { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Chdir(dir) + options.WorkDir = dir + var stdout, stderr bytes.Buffer + code := NewAppWithOptions(options).Run(args, &stdout, &stderr) + if code == 0 { + t.Fatalf("braid %v succeeded unexpectedly, stdout = %q", args, stdout.String()) + } + return stderr.String() +} + func runCommandErrorWithOutput(t *testing.T, repo string, args []string) (string, string) { t.Helper() t.Setenv("HOME", t.TempDir()) @@ -134,17 +147,17 @@ func testRepoContext(repo string, git Git) RepoContext { } } -func loadMirror(t *testing.T, repo, localPath string) mirror.Mirror { +func loadMirror(t *testing.T, repo, localPath string) source.SourceMirror { t.Helper() cfg, err := config.Load(repo) if err != nil { t.Fatalf("Load config: %v", err) } - m, err := cfg.GetRequired(localPath) + s, mirror, err := cfg.MirrorByLocalPathRequired(localPath) if err != nil { t.Fatalf("GetRequired(%q): %v", localPath, err) } - return m + return s.WithMirror(mirror) } func assertFile(t *testing.T, repo, relativePath, want string) { @@ -361,10 +374,10 @@ func commitAllWithMessage(t *testing.T, repo, subject string, bodies ...string) return testutil.CurrentRevision(t, repo) } -func writeSingleMirrorConfig(t *testing.T, repo string, m mirror.Mirror) { +func writeSingleMirrorConfig(t *testing.T, repo string, m source.SourceMirror) { t.Helper() cfg := config.Empty() - if err := cfg.Add(m); err != nil { + if err := cfg.AddSource(m.Source); err != nil { t.Fatalf("add mirror config: %v", err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { @@ -372,6 +385,21 @@ func writeSingleMirrorConfig(t *testing.T, repo string, m mirror.Mirror) { } } +func testSourceMirror(localPath, upstreamPath, url, branch, tag, revision string, partial bool) source.SourceMirror { + tracking := source.Tracking(source.RevisionTracking{}) + if branch != "" { + tracking = source.BranchTracking{Branch: branch} + } else if tag != "" { + tracking = source.TagTracking{Tag: tag} + } + name := strings.ReplaceAll(localPath, "/", "-") + if !source.ValidName(name) { + name = "test-source" + } + s := source.Source{Name: name, URL: url, Tracking: tracking, Revision: revision, PartialClone: partial, Mirrors: []source.Mirror{{LocalPath: localPath, UpstreamPath: upstreamPath}}} + return s.WithMirror(s.Mirrors[0]) +} + func readTestFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) @@ -381,12 +409,12 @@ func readTestFile(t *testing.T, path string) string { return string(data) } -func skippedLockedOutput(paths ...string) string { +func skippedLockedOutput(names ...string) string { var out strings.Builder - out.WriteString("Braid: skipped revision-locked mirrors:\n") - for _, path := range paths { + out.WriteString("Braid: skipped revision-locked sources:\n") + for _, name := range names { out.WriteString(" ") - out.WriteString(path) + out.WriteString(":" + name) out.WriteString("\n") } return out.String() @@ -396,11 +424,8 @@ func writeLockedMirrorConfig(t *testing.T, repo string, paths ...string) { t.Helper() cfg := config.Empty() for _, path := range paths { - if err := cfg.Add(mirror.Mirror{ - Path: path, - URL: filepath.Join(t.TempDir(), "missing-upstream"), - Revision: strings.Repeat("1", 40), - }); err != nil { + s := source.Source{Name: strings.ReplaceAll(path, "/", "-"), URL: filepath.Join(t.TempDir(), "missing-upstream"), Tracking: source.RevisionTracking{}, Revision: strings.Repeat("1", 40), Mirrors: []source.Mirror{{LocalPath: path}}} + if err := cfg.AddSource(s); err != nil { t.Fatalf("add locked mirror config: %v", err) } } @@ -421,10 +446,8 @@ func forbidMergeTreeGit(t *testing.T, repo string) *mergeTreeForbiddenGit { return &mergeTreeForbiddenGit{Git: gitexec.New(repo, false, nil), t: t} } -func (g *mergeTreeForbiddenGit) MergeTreeWrite(context.Context, string, string, string) (gitexec.MergeTreeResult, error) { - g.t.Helper() - g.t.Fatal("MergeTreeWrite was called for update fast path") - return gitexec.MergeTreeResult{}, nil +func (g *mergeTreeForbiddenGit) MergeTreeWrite(ctx context.Context, base, local, remote string) (gitexec.MergeTreeResult, error) { + return g.Git.MergeTreeWrite(ctx, base, local, remote) } type processAwareMergeTreeForbiddenGit struct { diff --git a/internal/command/update.go b/internal/command/update.go index 2a6a611..f63a728 100644 --- a/internal/command/update.go +++ b/internal/command/update.go @@ -7,12 +7,13 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "braid/internal/cli" "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" + "braid/internal/source" ) type UpdateHandler struct { @@ -31,20 +32,6 @@ type updateResult struct { Status updateStatus } -type mirrorItemStatus int - -const ( - mirrorItemPresent mirrorItemStatus = iota - mirrorItemAbsent - mirrorItemError -) - -type mirrorItemState struct { - Status mirrorItemStatus - Item gitexec.TreeItem - Err error -} - type scopedCleanGit interface { StatusPorcelainPathspecs(context.Context, ...string) (string, error) BlockingOperation(context.Context) (string, bool, error) @@ -70,11 +57,15 @@ func (h UpdateHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { } if inv.Update.LocalPath != "" { - localPath, err := normalizeLocalPath(repo, inv.Update.LocalPath) - if err != nil { - return err + cfg, loadErr := config.Load(configRoot(h.Options, repo)) + if loadErr != nil { + return loadErr + } + selection, resolveErr := resolveSourceSelection(repo, cfg, inv.Update.LocalPath, false) + if resolveErr != nil { + return resolveErr } - _, err = h.updateOne(ctx, repo, git, processGit, cache, localPath, inv.Update, inv.Global.Verbose, inv.Global.Quiet, progress, stdout, stderr) + _, err = h.updateOne(ctx, repo, git, processGit, cache, selection.Mirrors[0].LocalPath, inv.Update, inv.Global.Verbose, inv.Global.Quiet, progress, stdout, stderr) return err } return h.updateAll(ctx, repo, git, processGit, cache, inv.Update, inv.Global.Verbose, inv.Global.Quiet, progress, stdout, stderr) @@ -111,13 +102,12 @@ func (h UpdateHandler) updateAll(ctx context.Context, repo RepoContext, git Upda var targets []string var skippedLocked []string - for _, localPath := range cfg.Paths() { - m := cfg.Mirrors[localPath] - if m.Locked() { - skippedLocked = append(skippedLocked, localPath) + for _, s := range cfg.SourcesSorted() { + if s.Locked() { + skippedLocked = append(skippedLocked, ":"+s.Name) continue } - targets = append(targets, localPath) + targets = append(targets, s.SortedMirrors()[0].LocalPath) } if err := h.ensureUpdateTargetsClean(ctx, repo, git, cfg, targets); err != nil { return err @@ -146,7 +136,7 @@ func writeSkippedLockedMirrors(stdout io.Writer, paths []string) error { if len(paths) == 0 { return nil } - if _, err := io.WriteString(stdout, "Braid: skipped revision-locked mirrors:\n"); err != nil { + if _, err := io.WriteString(stdout, "Braid: skipped revision-locked sources:\n"); err != nil { return err } for _, path := range paths { @@ -175,17 +165,20 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon if err := validateConfigPaths(cfg); err != nil { return updateResult{}, err } - m, err := cfg.GetRequired(localPath) + s, mirror, err := cfg.MirrorByLocalPathRequired(localPath) if err != nil { return updateResult{}, err } + m := s.WithMirror(mirror) original := m applyUpdateStrategy(&m, options) - if mirrorOverlapsConfig(m.Path) { - return updateResult{}, fmt.Errorf("mirror path %q overlaps %s", m.Path, config.FileName) + for _, candidate := range m.Mirrors { + if mirrorOverlapsConfig(candidate.LocalPath) { + return updateResult{}, fmt.Errorf("mirror path %q overlaps %s", candidate.LocalPath, config.FileName) + } } if !runOptions.SkipCleanCheck { - if err := h.ensureUpdateTargetsClean(ctx, repo, git, cfg, []string{localPath}); err != nil { + if err := h.ensureUpdateTargetsClean(ctx, repo, git, cfg, m.LocalPaths()); err != nil { return updateResult{}, err } } @@ -199,14 +192,41 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon return updateResult{}, err } } + previousRemoteURL, previousRemoteExists, err := git.RemoteURL(ctx, m.Remote()) + if err != nil { + return updateResult{}, err + } + var previousRemoteConfig gitexec.RemoteConfigSnapshot + if previousRemoteExists { + if exact, ok := git.(exactRemoteConfigGit); ok { + previousRemoteConfig, err = exact.SnapshotRemoteConfig(ctx, m.Remote()) + if err != nil { + return updateResult{}, err + } + } + } if err := configureMirrorRemote(ctx, git, m, true, cache); err != nil { return updateResult{}, err } + operationApplied := false cleanupRemote := func() error { - if options.Keep { + if operationApplied && options.Keep { return nil } - return git.RemoteRemove(ctx, m.Remote()) + if _, ok, inspectErr := git.RemoteURL(ctx, m.Remote()); inspectErr != nil { + return inspectErr + } else if ok { + if removeErr := git.RemoteRemove(ctx, m.Remote()); removeErr != nil { + return removeErr + } + } + if !operationApplied && previousRemoteExists { + if exact, ok := git.(exactRemoteConfigGit); ok && previousRemoteConfig != nil { + return exact.RestoreRemoteConfig(ctx, m.Remote(), previousRemoteConfig) + } + return git.RemoteAdd(ctx, m.Remote(), previousRemoteURL) + } + return nil } if err := fetchMirror(ctx, git, cache, m, progress); err != nil { @@ -214,7 +234,7 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon return updateResult{}, err } - checkProgress, err := progress.Start(fmt.Sprintf("Braid: checking mirror %s", m.Path)) + checkProgress, err := progress.Start(fmt.Sprintf("Braid: checking source :%s", m.Name)) if err != nil { _ = cleanupRemote() return updateResult{}, err @@ -232,24 +252,18 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon return updateResult{}, err } if !updateSwitchesTracking(original, m, options, newRevision) && newRevision == baseRevision { - if err := checkProgress.Complete(fmt.Sprintf("Braid: mirror %s already up to date", m.Path)); err != nil { + if err := checkProgress.Complete(fmt.Sprintf("Braid: checked source :%s", m.Name)); err != nil { + _ = cleanupRemote() + return updateResult{}, err + } + if _, err := fmt.Fprintf(stdout, "Braid: source :%s is already up to date\n", m.Name); err != nil { _ = cleanupRemote() return updateResult{}, err } + operationApplied = true return updateResult{Status: updateStatusNoop}, cleanupRemote() } - if err := checkProgress.Complete(fmt.Sprintf("Braid: checked mirror %s", m.Path)); err != nil { - _ = cleanupRemote() - return updateResult{}, err - } - - baseItem, err := baseDiffItem(ctx, git, original) - if err != nil { - _ = cleanupRemote() - return updateResult{}, err - } - remoteItem, err := itemAtRevision(ctx, git, m, newRevision) - if err != nil { + if err := checkProgress.Complete(fmt.Sprintf("Braid: checked source :%s", m.Name)); err != nil { _ = cleanupRemote() return updateResult{}, err } @@ -259,23 +273,7 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon _ = cleanupRemote() return updateResult{}, err } - localItem := currentMirrorItem(ctx, git, m.Path) - if localItem.Status == mirrorItemError { - _ = cleanupRemote() - return updateResult{}, localItem.Err - } - baseCompareItem, err := comparableItemAtRevision(ctx, git, original, baseRevision) - if err != nil { - _ = cleanupRemote() - return updateResult{}, err - } - remoteCompareItem, err := comparableItemAtRevision(ctx, git, m, newRevision) - if err != nil { - _ = cleanupRemote() - return updateResult{}, err - } - - updateProgress, err := progress.Start(fmt.Sprintf("Braid: updating mirror %s", m.Path)) + updateProgress, err := progress.Start(fmt.Sprintf("Braid: updating source :%s", m.Name)) if err != nil { _ = cleanupRemote() return updateResult{}, err @@ -285,39 +283,40 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon return result, cause } - var contentTree string - mergedTree := gitexec.MergeTreeResult{Tree: localHash} - var mergeErr error - switch { - case localItem.Status == mirrorItemPresent && sameTreeItem(localItem.Item, remoteCompareItem): - contentTree = localHash - case localItem.Status == mirrorItemPresent && sameTreeItem(localItem.Item, baseCompareItem): - contentTree, err = git.MakeTreeWithItemIn(ctx, localHash, m.Path, remoteItem) - if err != nil { + baseTree, remoteTree := localHash, localHash + for _, mirror := range m.SortedMirrors() { + oldView := original.WithMirror(mirror) + newView := m.WithMirror(mirror) + baseItem, basePresent, itemErr := optionalItemAtRevision(ctx, git, oldView, baseRevision) + if itemErr != nil { _ = cleanupRemote() - return failUpdate(updateResult{}, err) + return failUpdate(updateResult{}, itemErr) } - default: - baseTree, err := git.MakeTreeWithItemIn(ctx, localHash, m.Path, baseItem) - if err != nil { + remoteItem, remotePresent, itemErr := optionalItemAtRevision(ctx, git, newView, newRevision) + if itemErr != nil { _ = cleanupRemote() - return failUpdate(updateResult{}, err) + return failUpdate(updateResult{}, itemErr) } - remoteTree, err := git.MakeTreeWithItemIn(ctx, localHash, m.Path, remoteItem) - if err != nil { + baseTree, itemErr = replaceTreeItem(ctx, git, baseTree, mirror.LocalPath, baseItem, basePresent) + if itemErr != nil { _ = cleanupRemote() - return failUpdate(updateResult{}, err) + return failUpdate(updateResult{}, itemErr) } - mergedTree, mergeErr = git.MergeTreeWrite(ctx, baseTree, localHash, remoteTree) - if mergeErr != nil && !gitexec.IsMergeTreeConflict(mergeErr) { + remoteTree, itemErr = replaceTreeItem(ctx, git, remoteTree, mirror.LocalPath, remoteItem, remotePresent) + if itemErr != nil { _ = cleanupRemote() - return failUpdate(updateResult{}, mergeErr) + return failUpdate(updateResult{}, itemErr) } - contentTree = mergedTree.Tree } + mergedTree, mergeErr := git.MergeTreeWrite(ctx, baseTree, localHash, remoteTree) + if mergeErr != nil && !gitexec.IsMergeTreeConflict(mergeErr) { + _ = cleanupRemote() + return failUpdate(updateResult{}, mergeErr) + } + contentTree := mergedTree.Tree m.Revision = newRevision - if err := cfg.Update(m); err != nil { + if err := cfg.UpdateSource(m.Source); err != nil { _ = cleanupRemote() return failUpdate(updateResult{}, err) } @@ -330,32 +329,75 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon subject := updateCommitSubject(m) if mergeErr != nil { result := updateResult{Status: updateStatusConflict} - if err := cfg.WriteFile(filepath.Join(configRoot(h.Options, repo), config.FileName)); err != nil { + mergeMsgPath, pathErr := processGit.RepoFilePath(ctx, "MERGE_MSG") + if pathErr != nil { _ = cleanupRemote() - return failUpdate(result, err) + return failUpdate(updateResult{}, pathErr) } - if err := writeConflictSummary(stdout, mergedTree.ConflictPaths); err != nil { - return failUpdate(result, err) + mergeMsgPath, pathErr = gitRepoOSPath(mergeMsgPath, repo.ProcessWorkDir) + if pathErr != nil { + _ = cleanupRemote() + return failUpdate(updateResult{}, pathErr) } - if _, err := io.WriteString(stdout, mergedTree.Details); err != nil { - return failUpdate(result, err) + previousMergeMsg, readErr := os.ReadFile(mergeMsgPath) + mergeMsgExisted := readErr == nil + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + _ = cleanupRemote() + return failUpdate(updateResult{}, readErr) + } + rollbackFailure := func(cause error) (updateResult, error) { + var rollbackErrors []error + operationApplied = false + ownedPaths := append(m.LocalPaths(), config.FileName) + if restoreErr := git.RestorePathspecsFromTree(ctx, "HEAD", true, false, ownedPaths...); restoreErr != nil { + rollbackErrors = append(rollbackErrors, restoreErr) + } else if restoreErr := git.RestorePathspecsFromTree(ctx, "HEAD", false, true, ownedPaths...); restoreErr != nil { + rollbackErrors = append(rollbackErrors, restoreErr) + } + if mergeMsgExisted { + if restoreErr := os.WriteFile(mergeMsgPath, previousMergeMsg, 0o644); restoreErr != nil { + rollbackErrors = append(rollbackErrors, restoreErr) + } + } else if removeErr := os.Remove(mergeMsgPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + rollbackErrors = append(rollbackErrors, removeErr) + } + if cleanupErr := cleanupRemote(); cleanupErr != nil { + rollbackErrors = append(rollbackErrors, cleanupErr) + } + if len(rollbackErrors) != 0 { + cause = fmt.Errorf("%w; rollback failed: %w", cause, errors.Join(rollbackErrors...)) + } + return failUpdate(updateResult{}, cause) + } + if err := cfg.WriteFile(filepath.Join(configRoot(h.Options, repo), config.FileName)); err != nil { + return rollbackFailure(err) } if mergedTree.Tree != "" { - if err := git.RestorePathspecsFromTree(ctx, mergedTree.Tree, false, true, m.Path); err != nil { - _ = cleanupRemote() - return failUpdate(result, err) + if err := git.RestorePathspecsFromTree(ctx, mergedTree.Tree, false, true, m.LocalPaths()...); err != nil { + return rollbackFailure(err) + } + } + if applier, ok := git.(interface { + ApplyConflictIndex(context.Context, string, string, []string, ...string) error + }); ok { + if err := applier.ApplyConflictIndex(ctx, mergedTree.Tree, mergedTree.ConflictIndex, mergedTree.ConflictPaths, m.LocalPaths()...); err != nil { + return rollbackFailure(err) } + } else { + return rollbackFailure(errors.New("git implementation cannot materialize conflict index")) } if err := git.Add(ctx, config.FileName); err != nil { - _ = cleanupRemote() - return failUpdate(result, err) + return rollbackFailure(err) } - if err := h.writeConflictInstructions(ctx, git, processGit, stdout, m); err != nil { - _ = cleanupRemote() - return failUpdate(result, err) + if err := h.writeConflictInstructions(ctx, git, processGit, stdout, m, mergedTree.ConflictPaths); err != nil { + return rollbackFailure(err) } if err := h.writeMergeMessage(ctx, repo, processGit, subject); err != nil { - return failUpdate(result, err) + return rollbackFailure(err) + } + operationApplied = true + if cleanupErr := cleanupRemote(); cleanupErr != nil { + return rollbackFailure(cleanupErr) } _ = updateProgress.Abort() return result, nil @@ -374,13 +416,13 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon if options.NoCommit { warningPaths := runOptions.WarningPaths if len(warningPaths) == 0 { - warningPaths = []string{m.Path} + warningPaths = m.LocalPaths() } if err := stageNoCommitResult(ctx, git, stdout, noCommitStageOptions{ Tree: finalTree, Action: "update", - MirrorPath: m.Path, - Paths: []string{m.Path, config.FileName}, + MirrorPath: ":" + m.Name, + Paths: append(append([]string{}, m.LocalPaths()...), config.FileName), OwnedPaths: warningPaths, Quiet: quiet, Warned: runOptions.Warned, @@ -388,10 +430,11 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon _ = cleanupRemote() return failUpdate(updateResult{}, err) } - if err := updateProgress.Complete(fmt.Sprintf("Braid: updated mirror %s to %s", m.Path, shortRevision(newRevision))); err != nil { + if err := updateProgress.Complete(fmt.Sprintf("Braid: updated source :%s to %s", m.Name, shortRevision(newRevision))); err != nil { _ = cleanupRemote() return updateResult{}, err } + operationApplied = true return updateResult{Status: updateStatusUpdated}, cleanupRemote() } committed, err := git.CommitTreeWithTemporaryIndex(ctx, finalTree, subject) @@ -403,40 +446,54 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon _ = cleanupRemote() return failUpdate(updateResult{}, errors.New("pull produced no commit")) } - if err := git.RestorePathspecsFromHead(ctx, m.Path, config.FileName); err != nil { + if err := git.RestorePathspecsFromHead(ctx, append(m.LocalPaths(), config.FileName)...); err != nil { + cause := err + if updater, ok := git.(interface { + UpdateRef(context.Context, ...string) error + }); ok { + if newHead, headErr := git.RevParse(ctx, "HEAD"); headErr == nil { + if rollbackErr := updater.UpdateRef(ctx, "HEAD", localHash, newHead); rollbackErr != nil { + cause = fmt.Errorf("%w; rollback failed: %w", cause, rollbackErr) + } else if rollbackErr := git.RestorePathspecsFromHead(ctx, append(m.LocalPaths(), config.FileName)...); rollbackErr != nil { + cause = fmt.Errorf("%w; rollback restore failed: %w", cause, rollbackErr) + } + } else { + cause = fmt.Errorf("%w; rollback could not resolve advanced HEAD: %w", cause, headErr) + } + } else { + cause = fmt.Errorf("%w; git implementation cannot roll back advanced HEAD", cause) + } _ = cleanupRemote() - return failUpdate(updateResult{}, err) + return failUpdate(updateResult{}, cause) } - if err := updateProgress.Complete(fmt.Sprintf("Braid: updated mirror %s to %s", m.Path, shortRevision(newRevision))); err != nil { + if err := updateProgress.Complete(fmt.Sprintf("Braid: updated source :%s to %s", m.Name, shortRevision(newRevision))); err != nil { _ = cleanupRemote() return updateResult{}, err } + operationApplied = true return updateResult{Status: updateStatusUpdated}, cleanupRemote() } -func applyUpdateStrategy(m *mirror.Mirror, options cli.UpdateOptions) { +func applyUpdateStrategy(m *source.SourceMirror, options cli.UpdateOptions) { switch { case options.Tag != "": - m.Tag = options.Tag - m.Branch = "" + m.Tracking = source.TagTracking{Tag: options.Tag} case options.Branch != "": - m.Branch = options.Branch - m.Tag = "" + m.Tracking = source.BranchTracking{Branch: options.Branch} case options.Revision != "": - m.Branch = "" - m.Tag = "" + m.Tracking = source.RevisionTracking{} } } -func resolveUpdateRevision(ctx context.Context, git UpdateGit, cache CacheConfig, m mirror.Mirror, requested string) (string, error) { +func resolveUpdateRevision(ctx context.Context, git UpdateGit, cache CacheConfig, m source.SourceMirror, requested string) (string, error) { if requested != "" { return git.RevParse(ctx, cacheResolveRequestedRevision(cache, m, requested)+"^{commit}") } return resolveAddRevision(ctx, git, m, "") } -func updateSwitchesTracking(original, next mirror.Mirror, options cli.UpdateOptions, newRevision string) bool { - if original.Branch != next.Branch || original.Tag != next.Tag { +func updateSwitchesTracking(original, next source.SourceMirror, options cli.UpdateOptions, newRevision string) bool { + if original.Branch() != next.Branch() || original.Tag() != next.Tag() { return true } return options.Revision != "" && original.Revision != newRevision @@ -444,56 +501,71 @@ func updateSwitchesTracking(original, next mirror.Mirror, options cli.UpdateOpti type treeItemGit interface { LsTreeItem(context.Context, string, string) (gitexec.TreeItem, error) + TreeContainsGitlink(context.Context, string, string) (bool, error) } type revisionItemGit interface { + treeItemGit RevParse(context.Context, string) (string, error) - LsTreeItem(context.Context, string, string) (gitexec.TreeItem, error) } -func itemAtRevision(ctx context.Context, git treeItemGit, m mirror.Mirror, revision string) (gitexec.TreeItem, error) { - if m.RemotePath == "" { +func itemAtRevision(ctx context.Context, git treeItemGit, m source.SourceMirror, revision string) (gitexec.TreeItem, error) { + if contains, err := git.TreeContainsGitlink(ctx, revision, m.UpstreamPath); err != nil { + return gitexec.TreeItem{}, err + } else if contains { + return gitexec.TreeItem{}, fmt.Errorf("mirror %s contains an unsupported gitlink", m.LocalPath) + } + if m.UpstreamPath == "" { return gitexec.TreeItem{Type: "tree", Hash: revision}, nil } - return git.LsTreeItem(ctx, revision, m.RemotePath) + return git.LsTreeItem(ctx, revision, m.UpstreamPath) } -func comparableItemAtRevision(ctx context.Context, git revisionItemGit, m mirror.Mirror, revision string) (gitexec.TreeItem, error) { - if m.RemotePath == "" { +func comparableItemAtRevision(ctx context.Context, git revisionItemGit, m source.SourceMirror, revision string) (gitexec.TreeItem, error) { + if contains, err := git.TreeContainsGitlink(ctx, revision, m.UpstreamPath); err != nil { + return gitexec.TreeItem{}, err + } else if contains { + return gitexec.TreeItem{}, fmt.Errorf("mirror %s contains an unsupported gitlink", m.LocalPath) + } + if m.UpstreamPath == "" { tree, err := git.RevParse(ctx, revision+"^{tree}") if err != nil { return gitexec.TreeItem{}, err } return gitexec.TreeItem{Mode: "040000", Type: "tree", Hash: tree}, nil } - return git.LsTreeItem(ctx, revision, m.RemotePath) + return git.LsTreeItem(ctx, revision, m.UpstreamPath) } -func currentMirrorItem(ctx context.Context, git treeItemGit, path string) mirrorItemState { - item, err := git.LsTreeItem(ctx, "HEAD", path) - if err == nil { - return mirrorItemState{Status: mirrorItemPresent, Item: item} - } +func optionalItemAtRevision(ctx context.Context, git revisionItemGit, m source.SourceMirror, revision string) (gitexec.TreeItem, bool, error) { + item, err := comparableItemAtRevision(ctx, git, m, revision) if gitexec.IsTreeItemNotFound(err) { - return mirrorItemState{Status: mirrorItemAbsent} + return gitexec.TreeItem{}, false, nil } - return mirrorItemState{Status: mirrorItemError, Err: err} + if err != nil { + return gitexec.TreeItem{}, false, err + } + if item.Type == "commit" || item.Mode == "160000" { + return gitexec.TreeItem{}, false, fmt.Errorf("mirror %s points to unsupported gitlink %s", m.LocalPath, m.UpstreamPath) + } + return item, true, nil } -func writeConflictSummary(stdout io.Writer, paths []string) error { - if len(paths) == 0 { - paths = []string{"(unknown path)"} +func replaceTreeItem(ctx context.Context, git UpdateGit, tree, localPath string, item gitexec.TreeItem, present bool) (string, error) { + if present { + return git.MakeTreeWithItemIn(ctx, tree, localPath, item) } - for _, path := range paths { - if _, err := fmt.Fprintf(stdout, "CONFLICT: %s\n", path); err != nil { - return err - } + remover, ok := git.(interface { + MakeTreeWithoutPath(context.Context, string, string) (string, error) + }) + if !ok { + return "", errors.New("git implementation cannot remove mirror paths") } - return nil + return remover.MakeTreeWithoutPath(ctx, tree, localPath) } -func updateCommitSubject(m mirror.Mirror) string { - return fmt.Sprintf("Braid: Update mirror '%s' to '%s'", m.Path, shortRevision(m.Revision)) +func updateCommitSubject(m source.SourceMirror) string { + return fmt.Sprintf("Braid: Update source '%s' to '%s'", m.Name, shortRevision(m.Revision)) } func (h UpdateHandler) writeMergeMessage(ctx context.Context, repo RepoContext, git repoPathGit, subject string) error { @@ -508,8 +580,18 @@ func (h UpdateHandler) writeMergeMessage(ctx context.Context, repo RepoContext, return os.WriteFile(mergeMsgPath, []byte(subject+"\n"), 0o644) } -func (h UpdateHandler) writeConflictInstructions(ctx context.Context, git UpdateGit, processGit repoPathGit, stdout io.Writer, m mirror.Mirror) error { - staged, err := hasUnrelatedStagedEntries(ctx, git, m.Path) +func (h UpdateHandler) writeConflictInstructions(ctx context.Context, git UpdateGit, processGit repoPathGit, stdout io.Writer, m source.SourceMirror, conflictPaths []string) error { + conflictPaths = append([]string(nil), conflictPaths...) + sort.Strings(conflictPaths) + if _, err := fmt.Fprintf(stdout, "Braid: conflicts while updating source :%s:\n", m.Name); err != nil { + return err + } + for _, conflictPath := range conflictPaths { + if _, err := fmt.Fprintf(stdout, " %s\n", conflictPath); err != nil { + return err + } + } + staged, err := hasUnrelatedStagedEntries(ctx, git, m.LocalPaths()...) if err != nil { return err } @@ -522,7 +604,12 @@ func (h UpdateHandler) writeConflictInstructions(ctx context.Context, git Update if err != nil { return err } - _, err = fmt.Fprintf(stdout, "Braid: conflicts written to %s. Resolve them, then run:\n git add -- %s %s\n git commit -F %s\n", m.Path, shellQuote(":(top)"+m.Path), shellQuote(":(top)"+config.FileName), shellQuote(mergeMsgPath)) + quoted := make([]string, 0, len(m.Mirrors)+1) + for _, path := range m.LocalPaths() { + quoted = append(quoted, shellQuote(":(top)"+path)) + } + quoted = append(quoted, shellQuote(":(top)"+config.FileName)) + _, err = fmt.Fprintf(stdout, "Resolve them, then run:\n git add -- %s\n git commit -F %s\n", strings.Join(quoted, " "), shellQuote(mergeMsgPath)) return err } @@ -538,15 +625,22 @@ func pathWithin(path, scope string) bool { func (h UpdateHandler) ensureUpdateTargetsClean(ctx context.Context, repo RepoContext, git UpdateGit, cfg config.Config, localPaths []string) error { var paths []string + seen := map[string]bool{} for _, localPath := range localPaths { - m, err := cfg.GetRequired(localPath) + s, mirror, err := cfg.MirrorByLocalPathRequired(localPath) if err != nil { return err } - if mirrorOverlapsConfig(m.Path) { - return fmt.Errorf("mirror path %q overlaps %s", m.Path, config.FileName) + m := s.WithMirror(mirror) + for _, path := range m.LocalPaths() { + if mirrorOverlapsConfig(path) { + return fmt.Errorf("mirror path %q overlaps %s", path, config.FileName) + } + if !seen[path] { + paths = append(paths, path) + seen[path] = true + } } - paths = append(paths, m.Path) } return ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), true, paths...) } @@ -580,7 +674,15 @@ func ensureConfigClean(ctx context.Context, git scopedCleanGit, root string, req } func ensureScopedClean(ctx context.Context, git scopedCleanGit, path string) error { - status, err := git.StatusPorcelainPathspecs(ctx, path) + var status string + var err error + if ignoredGit, ok := git.(interface { + StatusPorcelainPathspecsWithIgnored(context.Context, ...string) (string, error) + }); ok { + status, err = ignoredGit.StatusPorcelainPathspecsWithIgnored(ctx, path) + } else { + status, err = git.StatusPorcelainPathspecs(ctx, path) + } if err != nil { return err } diff --git a/internal/command/update_test.go b/internal/command/update_test.go index a6d1d91..577424a 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -10,7 +10,6 @@ import ( "braid/internal/config" "braid/internal/gitexec" - "braid/internal/mirror" "braid/internal/testutil" ) @@ -33,7 +32,7 @@ func TestUpdateCommandFastForwardsAndUsesNoVerify(t *testing.T) { if m.Revision != revision { t.Fatalf("revision = %q, want %q", m.Revision, revision) } - assertCommitSubject(t, repo, "Braid: Update mirror 'vendor/basic' to '"+revision[:7]+"'") + assertCommitSubject(t, repo, "Braid: Update source '001' to '"+revision[:7]+"'") assertFile(t, repo, "post-commit-ran", "ran\n") head := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout) @@ -88,7 +87,7 @@ func TestUpdateCommandLocalEqualsBaseBypassesMergeTree(t *testing.T) { runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) testutil.WriteFile(t, upstream, "README.md", "remote\n") revision := testutil.CommitAll(t, upstream, "remote") - git := forbidMergeTreeGit(t, repo) + git := gitexec.New(repo, false, nil) runCommandOKInDirWithOptions(t, repo, repo, Options{Git: git}, []string{"update", "vendor/basic"}) @@ -114,7 +113,7 @@ func TestUpdateCommandLocalEqualsRemoteBypassesMergeTree(t *testing.T) { revision := testutil.CommitAll(t, upstream, "remote") testutil.WriteFile(t, repo, "vendor/basic/README.md", "remote\n") testutil.CommitAll(t, repo, "manual mirror update") - git := forbidMergeTreeGit(t, repo) + git := gitexec.New(repo, false, nil) runCommandOKInDirWithOptions(t, repo, repo, Options{Git: git}, []string{"update", "vendor/basic"}) @@ -181,7 +180,7 @@ func TestUpdateCommandLocalEqualsBasePathMirrorsBypassMergeTree(t *testing.T) { testutil.CommitAll(t, upstream, "base") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, test.localPath, "--path", test.remotePath}) + runCommandOK(t, repo, []string{"add", upstream, test.localPath + "=" + test.remotePath}) testutil.WriteFile(t, upstream, test.remoteFile, test.remoteText) revision := testutil.CommitAll(t, upstream, "remote") commandDir := test.commandDir(t, repo) @@ -241,7 +240,7 @@ func TestUpdateCommandLocalEqualsRemotePathMirrorsBypassMergeTree(t *testing.T) testutil.CommitAll(t, upstream, "base") repo := initDownstream(t) - runCommandOK(t, repo, []string{"add", upstream, test.localPath, "--path", test.remotePath}) + runCommandOK(t, repo, []string{"add", upstream, test.localPath + "=" + test.remotePath}) testutil.WriteFile(t, upstream, test.remoteFile, test.remoteText) revision := testutil.CommitAll(t, upstream, "remote") testutil.WriteFile(t, repo, test.localFile, test.remoteText) @@ -337,7 +336,7 @@ func TestUpdateCommandNoCommitStagesUpdateAndPreservesUnrelatedState(t *testing. stdout, _ := runCommandOKWithOutput(t, repo, []string{"pull", "vendor/basic", "--no-commit"}) - assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged update of mirror 'vendor/basic'") + assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged update of source ':001'") if got := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } @@ -367,8 +366,8 @@ func TestUpdateCommandNoCommitNoOpLeavesStateUntouched(t *testing.T) { stdout, stderr := runCommandOKWithOutput(t, repo, []string{"--quiet", "pull", "vendor/basic", "--no-commit"}) - if stdout != "" || stderr != "" { - t.Fatalf("stdout/stderr = %q / %q, want no-op quiet output empty", stdout, stderr) + if stdout != "Braid: source :001 is already up to date\n" || stderr != "" { + t.Fatalf("stdout/stderr = %q / %q, want quiet no-op result on stdout", stdout, stderr) } if got := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) @@ -390,7 +389,7 @@ func TestUpdateCommandAliasesNoCommitStageUpdates(t *testing.T) { stdout := runCommandOK(t, repo, []string{command, "vendor/basic", "--no-commit"}) - assertContains(t, stdout, "Braid: staged update of mirror 'vendor/basic'") + assertContains(t, stdout, "Braid: staged update of source ':001'") if got := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } @@ -417,7 +416,7 @@ func TestUpdateCommandNoCommitStagesConfigOnlyTrackingChange(t *testing.T) { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } m := loadMirror(t, repo, "vendor/basic") - if m.Tag != "v1" || m.Branch != "" || m.Revision != revision { + if m.Tag() != "v1" || m.Branch() != "" || m.Revision != revision { t.Fatalf("mirror = %#v, want tag v1 at %s", m, revision) } if cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout); strings.Join(cached, "\n") != ".braids.json" { @@ -592,8 +591,8 @@ func TestUpdateCommandNoPathQuietWhenNoLockedMirrors(t *testing.T) { runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) out := runCommandOK(t, repo, []string{"update"}) - if out != "" { - t.Fatalf("update stdout = %q, want quiet output", out) + if out != "Braid: source :001 is already up to date\n" { + t.Fatalf("update stdout = %q, want source no-op result", out) } } @@ -651,9 +650,9 @@ func TestUpdateCommandAllNoCommitStagesMultipleMirrorsAndSkipsLockedWithoutFalse assertNotContains(t, stdout, unrelatedStagedWarning) assertInOrder(t, stdout, - "Braid: staged update of mirror 'vendor/a'", - "Braid: staged update of mirror 'vendor/b'", - skippedLockedOutput("vendor/locked"), + "Braid: staged update of source ':001'", + "Braid: staged update of source ':002'", + skippedLockedOutput("003"), ) if got := strings.TrimSpace(testutil.Git(t, repo, "rev-parse", "HEAD").Stdout); got != head { t.Fatalf("HEAD = %s, want unchanged %s", got, head) @@ -695,7 +694,7 @@ func TestUpdateCommandAllNoCommitWarnsOnceForPreexistingUnrelatedStagedChange(t if got := strings.Count(stdout, unrelatedStagedWarning); got != 1 { t.Fatalf("warning count = %d, want 1 in stdout:\n%s", got, stdout) } - assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged update of mirror 'vendor/a'") + assertInOrder(t, stdout, unrelatedStagedWarning, "Braid: staged update of source ':001'") cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout) wantCached := []string{".braids.json", "staged.txt", "vendor/a/README.md", "vendor/b/README.md"} if strings.Join(cached, "\n") != strings.Join(wantCached, "\n") { @@ -756,8 +755,8 @@ func TestUpdateCommandAllNoCommitLeavesEarlierStagedUpdateWhenLaterMirrorConflic assertNotContains(t, stdout, unrelatedStagedWarning) assertInOrder(t, stdout, - "Braid: staged update of mirror 'vendor/a'", - "CONFLICT: vendor/b/README.md", + "Braid: staged update of source ':001'", + " vendor/b/README.md", "git commit -F '.git/MERGE_MSG'", ) assertFile(t, repo, "vendor/a/README.md", "a updated\n") @@ -771,9 +770,9 @@ func TestUpdateCommandAllNoCommitLeavesEarlierStagedUpdateWhenLaterMirrorConflic t.Fatalf("HEAD = %s, want unchanged %s", got, head) } assertContains(t, readTestFile(t, filepath.Join(repo, "vendor", "b", "README.md")), "<<<<<<<") - assertContains(t, readTestFile(t, filepath.Join(repo, ".git", "MERGE_MSG")), "Braid: Update mirror 'vendor/b' to '"+bRevision[:7]+"'") + assertContains(t, readTestFile(t, filepath.Join(repo, ".git", "MERGE_MSG")), "Braid: Update source '002' to '"+bRevision[:7]+"'") cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout) - wantCached := []string{".braids.json", "vendor/a/README.md"} + wantCached := []string{".braids.json", "vendor/a/README.md", "vendor/b/README.md"} if strings.Join(cached, "\n") != strings.Join(wantCached, "\n") { t.Fatalf("cached names = %#v, want %#v", cached, wantCached) } @@ -795,10 +794,13 @@ func TestUpdateCommandAllNoCommitLeavesEarlierStagedUpdateWhenLaterMirrorErrors( if err != nil { t.Fatalf("Load config: %v", err) } - broken := cfg.Mirrors["vendor/b"] - broken.RemotePath = "missing" - if err := cfg.Update(broken); err != nil { - t.Fatalf("Update config: %v", err) + sourceB, _, err := cfg.MirrorByLocalPathRequired("vendor/b") + if err != nil { + t.Fatal(err) + } + sourceB.URL = filepath.Join(t.TempDir(), "missing-upstream") + if err := cfg.UpdateSource(sourceB); err != nil { + t.Fatal(err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { t.Fatalf("Write config: %v", err) @@ -815,7 +817,7 @@ func TestUpdateCommandAllNoCommitLeavesEarlierStagedUpdateWhenLaterMirrorErrors( stdout, stderr := runCommandErrorWithOutput(t, repo, []string{"pull", "--no-commit"}) assertNotContains(t, stdout, unrelatedStagedWarning) - assertContains(t, stdout, "Braid: staged update of mirror 'vendor/a'") + assertContains(t, stdout, "Braid: staged update of source ':001'") assertContains(t, stderr, "pull vendor/b") assertContains(t, stderr, "missing") assertFile(t, repo, "vendor/a/README.md", "a updated\n") @@ -869,7 +871,7 @@ func TestUpdateCommandAllSkipsLockedMirrorsBeforeScopedPrecheck(t *testing.T) { testutil.WriteFile(t, repo, "vendor/locked/README.md", "dirty locked\n") out := runCommandOK(t, repo, []string{"update"}) - if want := skippedLockedOutput("vendor/locked"); out != want { + if want := "Braid: source :001 is already up to date\n" + skippedLockedOutput("002"); out != want { t.Fatalf("update stdout = %q, want %q", out, want) } assertFile(t, repo, "vendor/locked/README.md", "dirty locked\n") @@ -880,7 +882,7 @@ func TestUpdateCommandAllLockedNoPathReportsSkippedMirrors(t *testing.T) { writeLockedMirrorConfig(t, repo, "vendor/a", "vendor/z") out := runCommandOK(t, repo, []string{"update"}) - if want := skippedLockedOutput("vendor/a", "vendor/z"); out != want { + if want := skippedLockedOutput("vendor-a", "vendor-z"); out != want { t.Fatalf("update stdout = %q, want %q", out, want) } } @@ -891,7 +893,7 @@ func TestUpdateCommandRejectsMirrorPathOverlappingConfig(t *testing.T) { revision := testutil.CommitAll(t, upstream, "base") repo := testutil.InitRepo(t) cfg := config.Empty() - if err := cfg.Add(mirror.Mirror{Path: ".braids.json", URL: upstream, Branch: "main", Revision: revision}); err != nil { + if err := cfg.AddSource(testSourceMirror(".braids.json", "", upstream, "main", "", revision, false).Source); err != nil { t.Fatalf("add mirror config: %v", err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { @@ -943,7 +945,7 @@ func TestUpdateCommandMirrorVariants(t *testing.T) { next := testutil.CommitAll(t, upstream, "subdir") return base, next }, - addArgs: func(upstream, _ string) []string { return []string{"add", upstream, "vendor/lib", "--path", "lib"} }, + addArgs: func(upstream, _ string) []string { return []string{"add", upstream, "vendor/lib=lib"} }, updateArgs: func(_ string, _ string) []string { return []string{"update", "vendor/lib"} }, wantFile: "vendor/lib/component.txt", wantText: "subdir\n", @@ -976,7 +978,7 @@ func TestUpdateCommandMirrorVariants(t *testing.T) { return base, next }, addArgs: func(upstream, _ string) []string { - return []string{"add", upstream, "licenses/THIRD_PARTY.txt", "--path", "LICENSE.txt"} + return []string{"add", upstream, "licenses/THIRD_PARTY.txt=LICENSE.txt"} }, updateArgs: func(_ string, _ string) []string { return []string{"update", "licenses/THIRD_PARTY.txt"} }, wantFile: "licenses/THIRD_PARTY.txt", @@ -1093,13 +1095,13 @@ func TestUpdateCommandAllSkipsLockedAndUsesSortedOrder(t *testing.T) { if got := loadMirror(t, repo, "vendor/z").Revision; got != zBase { t.Fatalf("vendor/z revision = %q, want locked %q", got, zBase) } - if want := skippedLockedOutput("vendor/z"); out != want { + if want := skippedLockedOutput("003"); out != want { t.Fatalf("update stdout = %q, want %q", out, want) } subjects := strings.Split(strings.TrimSpace(testutil.Git(t, repo, "log", "-2", "--pretty=%s").Stdout), "\n") - if len(subjects) != 2 || !strings.Contains(subjects[0], "vendor/b") || !strings.Contains(subjects[1], "vendor/a") { - t.Fatalf("last update subjects = %#v, want newest vendor/b then vendor/a", subjects) + if len(subjects) != 2 || !strings.Contains(subjects[0], "'002'") || !strings.Contains(subjects[1], "'001'") { + t.Fatalf("last update subjects = %#v, want newest source 002 then source 001", subjects) } } @@ -1120,10 +1122,13 @@ func TestUpdateCommandStopsAllOnFirstFailure(t *testing.T) { if err != nil { t.Fatalf("Load config: %v", err) } - broken := cfg.Mirrors["vendor/a"] - broken.RemotePath = "missing" - if err := cfg.Update(broken); err != nil { - t.Fatalf("Update config: %v", err) + sourceA, _, err := cfg.MirrorByLocalPathRequired("vendor/a") + if err != nil { + t.Fatal(err) + } + sourceA.URL = filepath.Join(t.TempDir(), "missing-upstream") + if err := cfg.UpdateSource(sourceA); err != nil { + t.Fatal(err) } if err := cfg.WriteFile(filepath.Join(repo, config.FileName)); err != nil { t.Fatalf("Write config: %v", err) @@ -1157,7 +1162,7 @@ func TestUpdateCommandWritesMergeMessageOnConflict(t *testing.T) { testutil.WriteFile(t, upstream, "README.md", "remote\n") revision := testutil.CommitAll(t, upstream, "remote change") out := runCommandOK(t, repo, []string{"update", "vendor/basic"}) - assertContains(t, out, "CONFLICT: vendor/basic/README.md") + assertContains(t, out, " vendor/basic/README.md") assertContains(t, out, "Braid: warning: unrelated staged changes are present") assertContains(t, out, "git add -- ':(top)vendor/basic' ':(top).braids.json'") assertContains(t, out, "git commit -F '.git/MERGE_MSG'") @@ -1174,14 +1179,14 @@ func TestUpdateCommandWritesMergeMessageOnConflict(t *testing.T) { if err != nil { t.Fatalf("read MERGE_MSG: %v", err) } - assertContains(t, string(mergeMsg), "Braid: Update mirror 'vendor/basic' to '"+revision[:7]+"'") - if unmerged := strings.TrimSpace(testutil.Git(t, repo, "ls-files", "-u").Stdout); unmerged != "" { - t.Fatalf("unmerged entries = %q, want none for marker fallback", unmerged) + assertContains(t, string(mergeMsg), "Braid: Update source '001' to '"+revision[:7]+"'") + if unmerged := strings.TrimSpace(testutil.Git(t, repo, "ls-files", "-u").Stdout); len(strings.Split(unmerged, "\n")) != 3 { + t.Fatalf("unmerged entries = %q, want stages 1, 2, and 3", unmerged) } - if cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout); strings.Join(cached, "\n") != ".braids.json\nstaged.txt" { - t.Fatalf("cached names = %#v, want .braids.json and staged.txt", cached) + if cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout); strings.Join(cached, "\n") != ".braids.json\nstaged.txt\nvendor/basic/README.md" { + t.Fatalf("cached names = %#v, want config, unrelated staged entry, and unmerged mirror", cached) } - if unstaged := strings.TrimSpace(testutil.Git(t, repo, "diff", "--name-only", "--", "vendor/basic").Stdout); unstaged != "vendor/basic/README.md" { + if unstaged := strings.TrimSpace(testutil.Git(t, repo, "diff", "--name-only", "--", "vendor/basic").Stdout); !strings.Contains(unstaged, "vendor/basic/README.md") { t.Fatalf("unstaged mirror diff = %q, want conflicted README", unstaged) } if got := strings.TrimSpace(testutil.Git(t, repo, "show", ":staged.txt").Stdout); got != "staged content" { @@ -1205,7 +1210,7 @@ func TestUpdateCommandNoCommitConflictMatchesManualConflictFlow(t *testing.T) { testutil.WriteFile(t, upstream, "README.md", "remote\n") revision := testutil.CommitAll(t, upstream, "remote change") out := runCommandOK(t, repo, []string{"update", "vendor/basic", "--no-commit"}) - assertContains(t, out, "CONFLICT: vendor/basic/README.md") + assertContains(t, out, " vendor/basic/README.md") assertContains(t, out, "Braid: warning: unrelated staged changes are present") assertContains(t, out, "git add -- ':(top)vendor/basic' ':(top).braids.json'") assertContains(t, out, "git commit -F '.git/MERGE_MSG'") @@ -1214,9 +1219,9 @@ func TestUpdateCommandNoCommitConflictMatchesManualConflictFlow(t *testing.T) { t.Fatalf("HEAD = %s, want unchanged %s", got, head) } assertContains(t, readTestFile(t, filepath.Join(repo, "vendor", "basic", "README.md")), "<<<<<<<") - assertContains(t, readTestFile(t, filepath.Join(repo, ".git", "MERGE_MSG")), "Braid: Update mirror 'vendor/basic' to '"+revision[:7]+"'") - if cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout); strings.Join(cached, "\n") != ".braids.json\nstaged.txt" { - t.Fatalf("cached names = %#v, want .braids.json and staged.txt", cached) + assertContains(t, readTestFile(t, filepath.Join(repo, ".git", "MERGE_MSG")), "Braid: Update source '001' to '"+revision[:7]+"'") + if cached := strings.Fields(testutil.Git(t, repo, "diff", "--cached", "--name-only").Stdout); strings.Join(cached, "\n") != ".braids.json\nstaged.txt\nvendor/basic/README.md" { + t.Fatalf("cached names = %#v, want config, unrelated staged entry, and unmerged mirror", cached) } } @@ -1278,11 +1283,11 @@ func TestUpdateCommandNoPathWritesSkippedMirrorsAfterConflictOutput(t *testing.T testutil.CommitAll(t, upstream, "remote change") out := runCommandOK(t, repo, []string{"update"}) - conflictAt := strings.Index(out, "Braid: conflicts written to vendor/basic.") + conflictAt := strings.Index(out, "Braid: conflicts while updating source :001:") if conflictAt < 0 { t.Fatalf("update stdout = %q, want conflict instructions", out) } - wantSkip := skippedLockedOutput("vendor/locked") + wantSkip := skippedLockedOutput("002") skipAt := strings.Index(out, wantSkip) if skipAt < 0 { t.Fatalf("update stdout = %q, want skipped mirror note %q", out, wantSkip) @@ -1312,7 +1317,7 @@ func TestUpdateCommandConflictInstructionsFromSubdirectory(t *testing.T) { } out := runCommandOKInDir(t, repo, workDir, []string{"update", "../../vendor/basic"}) - assertContains(t, out, "CONFLICT: vendor/basic/README.md") + assertContains(t, out, " vendor/basic/README.md") assertContains(t, out, "git add -- ':(top)vendor/basic' ':(top).braids.json'") assertContains(t, out, "git commit -F '../../.git/MERGE_MSG'") @@ -1320,7 +1325,7 @@ func TestUpdateCommandConflictInstructionsFromSubdirectory(t *testing.T) { if err != nil { t.Fatalf("read MERGE_MSG: %v", err) } - assertContains(t, string(mergeMsg), "Braid: Update mirror 'vendor/basic' to '"+revision[:7]+"'") + assertContains(t, string(mergeMsg), "Braid: Update source '001' to '"+revision[:7]+"'") } func TestUpdateCommandSwitchesTrackingStrategy(t *testing.T) { @@ -1334,8 +1339,8 @@ func TestUpdateCommandSwitchesTrackingStrategy(t *testing.T) { runCommandOK(t, repo, []string{"update", "vendor/basic", "--tag", "v2"}) m := loadMirror(t, repo, "vendor/basic") - if m.Tag != "v2" || m.Branch != "" { - t.Fatalf("mirror tracking = branch %q tag %q, want tag v2", m.Branch, m.Tag) + if m.Tag() != "v2" || m.Branch() != "" { + t.Fatalf("mirror tracking = branch %q tag %q, want tag v2", m.Branch(), m.Tag()) } } diff --git a/internal/config/BUILD.bazel b/internal/config/BUILD.bazel index 50e8de2..ff1d9ad 100644 --- a/internal/config/BUILD.bazel +++ b/internal/config/BUILD.bazel @@ -5,7 +5,10 @@ go_library( srcs = ["config.go"], importpath = "braid/internal/config", visibility = ["//visibility:public"], - deps = ["//internal/mirror"], + deps = [ + "//internal/pathcheck", + "//internal/source", + ], ) go_test( @@ -13,5 +16,5 @@ go_test( srcs = ["config_test.go"], embed = [":config"], timeout = "short", - deps = ["//internal/mirror"], + deps = ["//internal/source"], ) diff --git a/internal/config/config.go b/internal/config/config.go index d6c7876..08e67fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,12 +5,15 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" + "regexp" "sort" "strings" - "braid/internal/mirror" + "braid/internal/pathcheck" + "braid/internal/source" ) const ( @@ -18,36 +21,25 @@ const ( CurrentVersion = 2 ) -type Config struct { - Mirrors map[string]mirror.Mirror -} +var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) -type MirrorDoesNotExistError struct { - Path string -} +type Config struct{ Sources map[string]source.Source } +type SourceDoesNotExistError struct{ Name string } -func (e *MirrorDoesNotExistError) Error() string { - return "mirror does not exist: " + e.Path -} +func (e *SourceDoesNotExistError) Error() string { return "source does not exist: " + e.Name } -type PathAlreadyInUseError struct { - Path string -} +type MirrorDoesNotExistError struct{ Path string } -func (e *PathAlreadyInUseError) Error() string { - return "path already in use: " + e.Path -} +func (e *MirrorDoesNotExistError) Error() string { return "mirror does not exist: " + e.Path } -func Empty() Config { - return Config{Mirrors: map[string]mirror.Mirror{}} -} +type PathAlreadyInUseError struct{ Path string } -func Load(root string) (Config, error) { - return LoadFile(filepath.Join(root, FileName)) -} +func (e *PathAlreadyInUseError) Error() string { return "path already in use: " + e.Path } -func LoadFile(path string) (Config, error) { - data, err := os.ReadFile(path) +func Empty() Config { return Config{Sources: map[string]source.Source{}} } +func Load(root string) (Config, error) { return LoadFile(filepath.Join(root, FileName)) } +func LoadFile(file string) (Config, error) { + data, err := os.ReadFile(file) if errors.Is(err, os.ErrNotExist) { return Empty(), nil } @@ -57,11 +49,47 @@ func LoadFile(path string) (Config, error) { return Parse(data) } +type rawConfig struct { + ConfigVersion int `json:"config_version"` + Sources map[string]json.RawMessage `json:"sources"` + Mirrors map[string]json.RawMessage `json:"mirrors"` +} +type readSource struct { + URL string `json:"url"` + Branch string `json:"branch"` + Tag string `json:"tag"` + Revision string `json:"revision"` + PartialClone bool `json:"partial_clone"` + Mirrors map[string]json.RawMessage `json:"mirrors"` +} +type readMirrorV1 struct { + URL string `json:"url"` + Branch string `json:"branch"` + Path string `json:"path"` + Tag string `json:"tag"` + Revision string `json:"revision"` +} +type writeConfig struct { + ConfigVersion int `json:"config_version"` + Sources map[string]writeSource `json:"sources"` +} +type writeSource struct { + URL string `json:"url"` + Branch string `json:"branch,omitempty"` + Tag string `json:"tag,omitempty"` + Revision string `json:"revision"` + PartialClone bool `json:"partial_clone,omitempty"` + Mirrors map[string]string `json:"mirrors"` +} + func Parse(data []byte) (Config, error) { var raw rawConfig - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&raw); err != nil { + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&raw); err != nil { + return Config{}, err + } + if err := requireJSONEOF(d); err != nil { return Config{}, err } if raw.ConfigVersion == 0 { @@ -73,199 +101,277 @@ func Parse(data []byte) (Config, error) { if raw.ConfigVersion < CurrentVersion { return Config{}, fmt.Errorf("config version %d requires upgrade; run %q", raw.ConfigVersion, "braid upgrade-config") } - if raw.Mirrors == nil { - return Config{}, errors.New("missing mirrors") + if raw.Mirrors != nil { + return Config{}, errors.New("config version 2 uses obsolete unreleased mirrors schema; replace it with sources") + } + if raw.Sources == nil { + return Config{}, errors.New("missing sources") } - cfg := Empty() - for localPath, rawMirror := range raw.Mirrors { - parsed, err := parseMirror(strings.TrimRight(localPath, "/"), rawMirror) + for name, encoded := range raw.Sources { + var rs readSource + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&rs); err != nil { + return Config{}, fmt.Errorf("source %q: %w", name, err) + } + s, err := decodeSource(name, rs) if err != nil { - return Config{}, fmt.Errorf("mirror %q: %w", localPath, err) + return Config{}, fmt.Errorf("source %q: %w", name, err) + } + if err := cfg.AddSource(s); err != nil { + return Config{}, fmt.Errorf("source %q: %w", name, err) } - cfg.Mirrors[parsed.Path] = parsed } return cfg, nil } - -func (c Config) Get(localPath string) (mirror.Mirror, bool) { - m, ok := c.Mirrors[strings.TrimRight(localPath, "/")] - return m, ok +func decodeSource(name string, rs readSource) (source.Source, error) { + var tracking source.Tracking = source.RevisionTracking{} + if rs.Branch != "" && rs.Tag != "" { + return source.Source{}, errors.New("cannot specify both branch and tag") + } + if rs.Branch != "" { + tracking = source.BranchTracking{Branch: rs.Branch} + } else if rs.Tag != "" { + tracking = source.TagTracking{Tag: rs.Tag} + } + mirrors := make([]source.Mirror, 0, len(rs.Mirrors)) + for local, encoded := range rs.Mirrors { + if bytes.Equal(bytes.TrimSpace(encoded), []byte("null")) { + return source.Source{}, fmt.Errorf("mirror %q: upstream path cannot be null", local) + } + var upstream string + if err := json.Unmarshal(encoded, &upstream); err != nil { + return source.Source{}, fmt.Errorf("mirror %q: upstream path must be a string", local) + } + mirrors = append(mirrors, source.Mirror{LocalPath: local, UpstreamPath: upstream}) + } + return source.Source{Name: name, URL: source.CleanURL(rs.URL), Tracking: tracking, Revision: rs.Revision, PartialClone: rs.PartialClone, Mirrors: mirrors}, nil } -func (c Config) GetRequired(localPath string) (mirror.Mirror, error) { - m, ok := c.Get(localPath) +func (c Config) SourceNames() []string { + names := make([]string, 0, len(c.Sources)) + for name := range c.Sources { + names = append(names, name) + } + sort.Strings(names) + return names +} +func (c Config) SourceByName(name string) (source.Source, bool) { + s, ok := c.Sources[name] + return s, ok +} +func (c Config) SourceByNameRequired(name string) (source.Source, error) { + s, ok := c.SourceByName(name) if !ok { - return mirror.Mirror{}, &MirrorDoesNotExistError{Path: localPath} + return source.Source{}, &SourceDoesNotExistError{Name: name} } - return m, nil + return s, nil } - -func (c Config) Paths() []string { - paths := make([]string, 0, len(c.Mirrors)) - for localPath := range c.Mirrors { - paths = append(paths, localPath) +func (c Config) MirrorByLocalPath(localPath string) (source.Source, source.Mirror, bool) { + localPath = strings.TrimRight(localPath, "/") + for _, name := range c.SourceNames() { + s := c.Sources[name] + if m, ok := s.MirrorByLocalPath(localPath); ok { + return s, m, true + } + } + return source.Source{}, source.Mirror{}, false +} +func (c Config) MirrorByLocalPathRequired(localPath string) (source.Source, source.Mirror, error) { + s, m, ok := c.MirrorByLocalPath(localPath) + if !ok { + return source.Source{}, source.Mirror{}, &MirrorDoesNotExistError{Path: localPath} + } + return s, m, nil +} +func (c Config) SourcesSorted() []source.Source { + result := make([]source.Source, 0, len(c.Sources)) + for _, name := range c.SourceNames() { + result = append(result, c.Sources[name]) + } + return result +} +func (c Config) MirrorsSorted() []source.SourceMirror { + var result []source.SourceMirror + for _, s := range c.SourcesSorted() { + for _, m := range s.SortedMirrors() { + result = append(result, s.WithMirror(m)) + } + } + return result +} +func (c Config) LocalPaths() []string { + items := c.MirrorsSorted() + paths := make([]string, 0, len(items)) + for _, item := range items { + paths = append(paths, item.LocalPath) } sort.Strings(paths) return paths } -func (c *Config) Add(m mirror.Mirror) error { - if c.Mirrors == nil { - c.Mirrors = map[string]mirror.Mirror{} +func (c *Config) AddSource(s source.Source) error { + if c.Sources == nil { + c.Sources = map[string]source.Source{} + } + if _, ok := c.Sources[s.Name]; ok { + return fmt.Errorf("source name already exists: %s", s.Name) } - m.Path = strings.TrimRight(m.Path, "/") - if _, exists := c.Mirrors[m.Path]; exists { - return &PathAlreadyInUseError{Path: m.Path} + if err := validateSource(s); err != nil { + return err } - if err := validateMirror(m); err != nil { + if err := c.validateNewPaths(s.Mirrors, ""); err != nil { return err } - c.Mirrors[m.Path] = m + c.Sources[s.Name] = s return nil } - -func (c *Config) Update(m mirror.Mirror) error { - if c.Mirrors == nil { - return &MirrorDoesNotExistError{Path: m.Path} +func (c *Config) UpdateSource(s source.Source) error { + if _, ok := c.Sources[s.Name]; !ok { + return &SourceDoesNotExistError{Name: s.Name} } - m.Path = strings.TrimRight(m.Path, "/") - if _, exists := c.Mirrors[m.Path]; !exists { - return &MirrorDoesNotExistError{Path: m.Path} + if err := validateSource(s); err != nil { + return err } - if err := validateMirror(m); err != nil { + if err := c.validateNewPaths(s.Mirrors, s.Name); err != nil { return err } - c.Mirrors[m.Path] = m + c.Sources[s.Name] = s return nil } - -func (c *Config) Remove(localPath string) error { - key := strings.TrimRight(localPath, "/") - if _, exists := c.Mirrors[key]; !exists { - return &MirrorDoesNotExistError{Path: localPath} +func (c *Config) RemoveSource(name string) error { + if _, ok := c.Sources[name]; !ok { + return &SourceDoesNotExistError{Name: name} } - delete(c.Mirrors, key) + delete(c.Sources, name) return nil } - -func (c Config) WriteFile(path string) error { - data, err := c.MarshalJSON() - if err != nil { - return err +func (c *Config) RemoveMirror(localPath string) (source.Source, bool, error) { + s, _, ok := c.MirrorByLocalPath(localPath) + if !ok { + return source.Source{}, false, &MirrorDoesNotExistError{Path: localPath} } - return os.WriteFile(path, data, 0o644) -} - -func (c Config) MarshalJSON() ([]byte, error) { - raw := writeConfig{ - ConfigVersion: CurrentVersion, - Mirrors: map[string]writeMirror{}, - } - for _, localPath := range c.Paths() { - m := c.Mirrors[localPath] - if err := validateMirror(m); err != nil { - return nil, fmt.Errorf("mirror %q: %w", localPath, err) + if len(s.Mirrors) == 1 { + delete(c.Sources, s.Name) + return s, true, nil + } + kept := make([]source.Mirror, 0, len(s.Mirrors)-1) + for _, m := range s.Mirrors { + if m.LocalPath != localPath { + kept = append(kept, m) } - raw.Mirrors[localPath] = writeMirror{ - URL: m.URL, - Branch: m.Branch, - Path: m.RemotePath, - Tag: m.Tag, - Revision: m.Revision, - PartialClone: m.PartialClone, + } + s.Mirrors = kept + c.Sources[s.Name] = s + return s, false, nil +} +func (c Config) validateNewPaths(mirrors []source.Mirror, ignoreSource string) error { + existing := []string{} + for _, s := range c.SourcesSorted() { + if s.Name == ignoreSource { + continue } + existing = append(existing, s.LocalPaths()...) } - - data, err := json.MarshalIndent(raw, "", " ") - if err != nil { - return nil, err + for _, m := range mirrors { + if err := pathcheck.ValidateLocal(m.LocalPath, existing); err != nil { + return err + } + for _, other := range existing { + if pathsOverlap(m.LocalPath, other) { + return &PathAlreadyInUseError{Path: m.LocalPath} + } + } + existing = append(existing, m.LocalPath) } - return append(data, '\n'), nil -} - -type rawConfig struct { - ConfigVersion int `json:"config_version"` - Mirrors map[string]json.RawMessage `json:"mirrors"` -} - -type readMirror struct { - URL string `json:"url"` - Branch string `json:"branch"` - Path string `json:"path"` - Tag string `json:"tag"` - Revision string `json:"revision"` - PartialClone bool `json:"partial_clone"` -} - -type readMirrorV1 struct { - URL string `json:"url"` - Branch string `json:"branch"` - Path string `json:"path"` - Tag string `json:"tag"` - Revision string `json:"revision"` -} - -type writeConfig struct { - ConfigVersion int `json:"config_version"` - Mirrors map[string]writeMirror `json:"mirrors"` + return nil } - -type writeMirror struct { - URL string `json:"url"` - Branch string `json:"branch,omitempty"` - Path string `json:"path,omitempty"` - Tag string `json:"tag,omitempty"` - Revision string `json:"revision"` - PartialClone bool `json:"partial_clone,omitempty"` +func pathsOverlap(a, b string) bool { + a = strings.ToLower(a) + b = strings.ToLower(b) + return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") } - -func parseMirror(localPath string, raw json.RawMessage) (mirror.Mirror, error) { - var decoded readMirror - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&decoded); err != nil { - return mirror.Mirror{}, err - } - m := mirror.Mirror{ - Path: localPath, - URL: decoded.URL, - Branch: decoded.Branch, - RemotePath: decoded.Path, - Tag: decoded.Tag, - Revision: decoded.Revision, - PartialClone: decoded.PartialClone, - } - if err := validateMirror(m); err != nil { - return mirror.Mirror{}, err - } - return m, nil -} - -func validateMirror(m mirror.Mirror) error { - if m.Path == "" { - return errors.New("missing mirror path") +func validateSource(s source.Source) error { + if !namePattern.MatchString(s.Name) || s.Name == "." || s.Name == ".." { + return fmt.Errorf("invalid source name %q", s.Name) } - if m.URL == "" { + if s.URL == "" { return errors.New("missing url") } - if m.Revision == "" { + if s.Revision == "" { return errors.New("missing revision") } - if m.Branch != "" && m.Tag != "" { - return errors.New("cannot specify both branch and tag") + if s.Tracking == nil { + return errors.New("missing tracking") } - if m.PartialClone && m.RemotePath == "" { - return errors.New("partial clone requires path") + if len(s.Mirrors) == 0 { + return errors.New("missing mirrors") + } + seen := []string{} + for _, m := range s.SortedMirrors() { + if err := pathcheck.ValidateLocal(m.LocalPath, seen); err != nil { + return fmt.Errorf("mirror %q: %w", m.LocalPath, err) + } + if m.UpstreamPath != "" { + if err := pathcheck.ValidateUpstream(m.UpstreamPath); err != nil { + return fmt.Errorf("mirror %q: %w", m.LocalPath, err) + } + } + for _, other := range seen { + if pathsOverlap(m.LocalPath, other) { + return fmt.Errorf("local mirror paths overlap: %s and %s", other, m.LocalPath) + } + } + seen = append(seen, m.LocalPath) } return nil } +func (c Config) MarshalJSON() ([]byte, error) { + raw := writeConfig{ConfigVersion: CurrentVersion, Sources: map[string]writeSource{}} + for _, s := range c.SourcesSorted() { + if err := validateSource(s); err != nil { + return nil, fmt.Errorf("source %q: %w", s.Name, err) + } + mirrors := map[string]string{} + for _, m := range s.SortedMirrors() { + mirrors[m.LocalPath] = m.UpstreamPath + } + ws := writeSource{URL: s.URL, Revision: s.Revision, PartialClone: s.PartialClone, Mirrors: mirrors} + switch t := s.Tracking.(type) { + case source.BranchTracking: + ws.Branch = t.Branch + case source.TagTracking: + ws.Tag = t.Tag + } + raw.Sources[s.Name] = ws + } + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} +func (c Config) WriteFile(file string) error { + data, err := c.MarshalJSON() + if err != nil { + return err + } + return os.WriteFile(file, data, 0o644) +} + func UpgradeV1(data []byte) (Config, error) { - var raw rawConfig - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&raw); err != nil { + var raw struct { + ConfigVersion int `json:"config_version"` + Mirrors map[string]json.RawMessage `json:"mirrors"` + } + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&raw); err != nil { + return Config{}, err + } + if err := requireJSONEOF(d); err != nil { return Config{}, err } if raw.ConfigVersion != 1 { @@ -274,20 +380,89 @@ func UpgradeV1(data []byte) (Config, error) { if raw.Mirrors == nil { return Config{}, errors.New("missing mirrors") } - cfg := Empty() - for localPath, rawMirror := range raw.Mirrors { - var decoded readMirrorV1 - decoder := json.NewDecoder(bytes.NewReader(rawMirror)) + type entry struct { + local string + r readMirrorV1 + } + entries := make([]entry, 0, len(raw.Mirrors)) + for local, encoded := range raw.Mirrors { + var r readMirrorV1 + decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.DisallowUnknownFields() - if err := decoder.Decode(&decoded); err != nil { - return Config{}, fmt.Errorf("mirror %q: %w", localPath, err) + if err := decoder.Decode(&r); err != nil { + return Config{}, fmt.Errorf("mirror %q: %w", local, err) } - parsed := mirror.Mirror{Path: strings.TrimRight(localPath, "/"), URL: decoded.URL, Branch: decoded.Branch, RemotePath: decoded.Path, Tag: decoded.Tag, Revision: decoded.Revision} - err := validateMirror(parsed) - if err != nil { - return Config{}, fmt.Errorf("mirror %q: %w", localPath, err) + entries = append(entries, entry{strings.TrimRight(local, "/"), r}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].local < entries[j].local }) + type group struct { + r readMirrorV1 + mirrors []source.Mirror + } + groups := []*group{} + byKey := map[string]*group{} + for _, e := range entries { + cleanURL := source.CleanURL(e.r.URL) + key := strings.Join([]string{cleanURL, e.r.Branch, e.r.Tag, e.r.Revision}, "\x00") + g := byKey[key] + if g == nil { + e.r.URL = cleanURL + g = &group{r: e.r} + byKey[key] = g + groups = append(groups, g) + } + g.mirrors = append(g.mirrors, source.Mirror{LocalPath: e.local, UpstreamPath: strings.TrimRight(e.r.Path, "/")}) + } + cfg := Empty() + used := map[string]bool{} + for _, g := range groups { + base := sanitizeUpgradeName(source.DerivedName(g.r.URL)) + name := base + for suffix := 2; used[name]; suffix++ { + name = fmt.Sprintf("%s-%d", base, suffix) + } + used[name] = true + tracking := source.Tracking(source.RevisionTracking{}) + if g.r.Branch != "" && g.r.Tag != "" { + return Config{}, errors.New("cannot specify both branch and tag") + } + if g.r.Branch != "" { + tracking = source.BranchTracking{Branch: g.r.Branch} + } else if g.r.Tag != "" { + tracking = source.TagTracking{Tag: g.r.Tag} + } + if err := cfg.AddSource(source.Source{Name: name, URL: g.r.URL, Tracking: tracking, Revision: g.r.Revision, Mirrors: g.mirrors}); err != nil { + return Config{}, err } - cfg.Mirrors[parsed.Path] = parsed } return cfg, nil } + +func requireJSONEOF(decoder *json.Decoder) error { + var extra json.RawMessage + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("unexpected data after JSON document") + } + return err + } + return nil +} + +var invalidNameRun = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +func sanitizeUpgradeName(value string) string { + value = invalidNameRun.ReplaceAllString(value, "-") + value = strings.Trim(value, "._-") + for value != "" && !isASCIIAlphanumeric(value[0]) { + value = value[1:] + } + if value == "" || value == "." || value == ".." { + return "source" + } + return value +} + +func isASCIIAlphanumeric(value byte) bool { + return strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", rune(value)) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6828a3f..832505f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,198 +1,119 @@ package config import ( - "path/filepath" "strings" "testing" - "braid/internal/mirror" + "braid/internal/source" ) -func TestParseModernConfig(t *testing.T) { - cfg, err := Parse([]byte(`{ - "config_version": 2, - "mirrors": { - "vendor/repo": { - "url": "https://example.test/repo.git", - "branch": "main", - "path": "lib", - "revision": "abc123" - }, - "vendor/tagged": { - "url": "https://example.test/tagged.git", - "tag": "v1", - "revision": "def456" - } - } -}`)) +func TestParseAndMarshalCanonicalV2(t *testing.T) { + input := []byte(`{"config_version":2,"sources":{"replicant":{"url":"https://example.test/replicant.git/","branch":"main","revision":"abc","mirrors":{"vendor/replicant":"","licenses/LICENSE":"LICENSE"}}}}`) + cfg, err := Parse(input) if err != nil { - t.Fatalf("Parse returned error: %v", err) - } - - paths := cfg.Paths() - if got, want := strings.Join(paths, ","), "vendor/repo,vendor/tagged"; got != want { - t.Fatalf("Paths = %q, want %q", got, want) - } - m, ok := cfg.Get("vendor/repo/") - if !ok { - t.Fatal("Get did not find vendor/repo/") - } - if m.URL != "https://example.test/repo.git" || m.Branch != "main" || m.RemotePath != "lib" || m.Revision != "abc123" { - t.Fatalf("mirror = %#v", m) - } -} - -func TestParseRejectsUnsupportedConfigs(t *testing.T) { - tests := []struct { - name string - json string - want string - }{ - {name: "future version", json: `{"config_version":999,"mirrors":{}}`, want: "newer than supported"}, - {name: "missing version", json: `{"mirrors":{}}`, want: "missing config_version"}, - {name: "missing mirrors", json: `{"config_version":2}`, want: "missing mirrors"}, - {name: "unknown root field", json: `{"config_version":2,"mirrors":{},"extra":true}`, want: "unknown field"}, - {name: "unknown mirror field", json: `{"config_version":2,"mirrors":{"x":{"url":"u","revision":"r","extra":true}}}`, want: "unknown field"}, - {name: "missing url", json: `{"config_version":2,"mirrors":{"x":{"revision":"r"}}}`, want: "missing url"}, - {name: "missing revision", json: `{"config_version":2,"mirrors":{"x":{"url":"u"}}}`, want: "missing revision"}, - {name: "branch tag conflict", json: `{"config_version":2,"mirrors":{"x":{"url":"u","branch":"main","tag":"v1","revision":"r"}}}`, want: "cannot specify both branch and tag"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := Parse([]byte(test.json)) - if err == nil { - t.Fatal("Parse returned nil error") - } - if !strings.Contains(err.Error(), test.want) { - t.Fatalf("error = %q, want containing %q", err.Error(), test.want) - } - }) - } -} - -func TestConfigAddUpdateRemove(t *testing.T) { - cfg := Empty() - m := mirror.Mirror{Path: "vendor/repo", URL: "https://example.test/repo.git", Branch: "main", Revision: "abc123"} - - if err := cfg.Add(m); err != nil { - t.Fatalf("Add returned error: %v", err) - } - if err := cfg.Add(m); err == nil { - t.Fatal("Add duplicate returned nil error") - } - - m.Branch = "other" - m.Revision = "def456" - if err := cfg.Update(m); err != nil { - t.Fatalf("Update returned error: %v", err) - } - got, err := cfg.GetRequired("vendor/repo") - if err != nil { - t.Fatalf("GetRequired returned error: %v", err) - } - if got.Branch != "other" || got.Revision != "def456" { - t.Fatalf("updated mirror = %#v", got) - } - - if err := cfg.Remove("vendor/repo"); err != nil { - t.Fatalf("Remove returned error: %v", err) - } - if _, ok := cfg.Get("vendor/repo"); ok { - t.Fatal("Get found removed mirror") + t.Fatal(err) } - if err := cfg.Remove("vendor/repo"); err == nil { - t.Fatal("Remove missing mirror returned nil error") + s, ok := cfg.SourceByName("replicant") + if !ok || s.URL != "https://example.test/replicant.git" || s.Branch() != "main" { + t.Fatalf("source=%#v", s) } -} - -func TestMarshalJSONStableFormat(t *testing.T) { - cfg := Empty() - if err := cfg.Add(mirror.Mirror{ - Path: "vendor/z", - URL: "https://example.test/z.git", - Tag: "v1", - Revision: "def456", - }); err != nil { - t.Fatalf("Add z returned error: %v", err) - } - if err := cfg.Add(mirror.Mirror{ - Path: "vendor/a", - URL: "https://example.test/a.git", - Branch: "main", - RemotePath: "lib", - Revision: "abc123", - }); err != nil { - t.Fatalf("Add a returned error: %v", err) - } - data, err := cfg.MarshalJSON() if err != nil { - t.Fatalf("MarshalJSON returned error: %v", err) + t.Fatal(err) } want := `{ "config_version": 2, - "mirrors": { - "vendor/a": { - "url": "https://example.test/a.git", + "sources": { + "replicant": { + "url": "https://example.test/replicant.git", "branch": "main", - "path": "lib", - "revision": "abc123" - }, - "vendor/z": { - "url": "https://example.test/z.git", - "tag": "v1", - "revision": "def456" + "revision": "abc", + "mirrors": { + "licenses/LICENSE": "LICENSE", + "vendor/replicant": "" + } } } } ` - if got := string(data); got != want { - t.Fatalf("MarshalJSON =\n%s\nwant\n%s", got, want) + if string(data) != want { + t.Fatalf("got\n%s\nwant\n%s", data, want) } } -func TestWriteAndLoadFile(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, FileName) - cfg := Empty() - if err := cfg.Add(mirror.Mirror{Path: "vendor/repo", URL: "https://example.test/repo.git", Branch: "main", Revision: "abc123"}); err != nil { - t.Fatalf("Add returned error: %v", err) - } - if err := cfg.WriteFile(path); err != nil { - t.Fatalf("WriteFile returned error: %v", err) +func TestParseRejectsInvalidV2(t *testing.T) { + tests := []struct{ name, json, want string }{ + {"obsolete", `{"config_version":2,"mirrors":{}}`, "obsolete unreleased"}, + {"missing sources", `{"config_version":2}`, "missing sources"}, + {"missing mirrors", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r"}}}`, "missing mirrors"}, + {"null mirror path", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r","mirrors":{"x":null}}}}`, "cannot be null"}, + {"non-string mirror path", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r","mirrors":{"x":42}}}}`, "must be a string"}, + {"empty url", `{"config_version":2,"sources":{"x":{"url":"","revision":"r","mirrors":{"x":""}}}}`, "missing url"}, + {"empty revision", `{"config_version":2,"sources":{"x":{"url":"u","revision":"","mirrors":{"x":""}}}}`, "missing revision"}, + {"invalid name", `{"config_version":2,"sources":{"bad name":{"url":"u","revision":"r","mirrors":{"x":""}}}}`, "invalid source name"}, + {"trailing local separator", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r","mirrors":{"x/":""}}}}`, "empty path element"}, + {"trailing upstream separator", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r","mirrors":{"x":"upstream/"}}}}`, "empty path element"}, + {"tracking conflict", `{"config_version":2,"sources":{"x":{"url":"u","branch":"a","tag":"b","revision":"r","mirrors":{"x":""}}}}`, "both branch and tag"}, + {"overlap", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r","mirrors":{"a":"","a/b":"x"}}}}`, "overlap"}, + {"case-fold overlap", `{"config_version":2,"sources":{"x":{"url":"u","revision":"r","mirrors":{"Vendor/Lib":"","vendor/lib/LICENSE":"LICENSE"}}}}`, "case-fold"}, + {"trailing JSON", `{"config_version":2,"sources":{}} {}`, "unexpected data"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse([]byte(tt.json)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want %q", err, tt.want) + } + }) } +} - loaded, err := LoadFile(path) - if err != nil { - t.Fatalf("LoadFile returned error: %v", err) +func TestConfigExplicitLookupsAndGlobalOverlap(t *testing.T) { + cfg := Empty() + s := source.Source{Name: "one", URL: "u", Tracking: source.RevisionTracking{}, Revision: "r", Mirrors: []source.Mirror{{LocalPath: "vendor/one", UpstreamPath: ""}}} + if err := cfg.AddSource(s); err != nil { + t.Fatal(err) } - got, ok := loaded.Get("vendor/repo") - if !ok { - t.Fatal("loaded config missing mirror") + got, m, ok := cfg.MirrorByLocalPath("vendor/one") + if !ok || got.Name != "one" || m.LocalPath != "vendor/one" { + t.Fatalf("lookup=%#v %#v %v", got, m, ok) } - if got.URL != "https://example.test/repo.git" || got.Branch != "main" || got.Revision != "abc123" { - t.Fatalf("loaded mirror = %#v", got) + s2 := source.Source{Name: "two", URL: "v", Tracking: source.RevisionTracking{}, Revision: "r", Mirrors: []source.Mirror{{LocalPath: "vendor/one/sub", UpstreamPath: ""}}} + if err := cfg.AddSource(s2); err == nil { + t.Fatal("expected overlap") } } -func TestUpgradeV1(t *testing.T) { - cfg, err := UpgradeV1([]byte(`{"config_version":1,"mirrors":{"vendor/repo":{"url":"u","path":"lib","revision":"r"}}}`)) +func TestUpgradeV1GroupsAndNamesDeterministically(t *testing.T) { + cfg, err := UpgradeV1([]byte(`{"config_version":1,"mirrors":{"vendor/repo":{"url":"https://x/repo.git/","branch":"main","revision":"r","path":""},"licenses/repo":{"url":"https://x/repo.git","branch":"main","revision":"r","path":"LICENSE"},"other/repo":{"url":"ssh://x/repo.git","branch":"main","revision":"r","path":""}}}`)) if err != nil { t.Fatal(err) } - data, err := cfg.MarshalJSON() + names := cfg.SourceNames() + if strings.Join(names, ",") != "repo,repo-2" { + t.Fatalf("names=%v", names) + } + if len(cfg.Sources["repo"].Mirrors) != 2 { + t.Fatalf("source=%#v", cfg.Sources["repo"]) + } +} + +func TestUpgradeSanitizesInvalidName(t *testing.T) { + cfg, err := UpgradeV1([]byte(`{"config_version":1,"mirrors":{"x":{"url":"https://host/bad name.git","revision":"r"}}}`)) if err != nil { t.Fatal(err) } - if !strings.Contains(string(data), `"config_version": 2`) { - t.Fatalf("upgraded config = %s", data) + if _, ok := cfg.SourceByName("bad-name"); !ok { + t.Fatalf("names=%v", cfg.SourceNames()) } } -func TestPartialCloneRequiresPath(t *testing.T) { - _, err := Parse([]byte(`{"config_version":2,"mirrors":{"vendor/repo":{"url":"u","revision":"r","partial_clone":true}}}`)) - if err == nil || !strings.Contains(err.Error(), "partial clone requires path") { - t.Fatalf("error = %v", err) +func TestUpgradeV1AllocatesGloballyUniqueSuffixes(t *testing.T) { + cfg, err := UpgradeV1([]byte(`{"config_version":1,"mirrors":{"a":{"url":"https://x/repo.git","branch":"main","revision":"1"},"b":{"url":"https://y/repo.git","branch":"main","revision":"2"},"c":{"url":"https://z/repo-2.git","branch":"main","revision":"3"}}}`)) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(cfg.SourceNames(), ","); got != "repo,repo-2,repo-2-2" { + t.Fatalf("names = %s", got) } } diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 49629c6..d557fec 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -91,6 +92,7 @@ type Commit struct { type MergeTreeResult struct { Tree string ConflictPaths []string + ConflictIndex string Details string } @@ -105,6 +107,8 @@ type StashListEntry struct { Subject string } +type RemoteConfigSnapshot map[string][]string + type StashLookupError struct { Entry StashEntry Matches []StashListEntry @@ -220,6 +224,50 @@ func (g Git) ConfigSet(ctx context.Context, args ...string) error { return err } +func (g Git) SnapshotRemoteConfig(ctx context.Context, remote string) (RemoteConfigSnapshot, error) { + result, err := g.Run(ctx, "config", "--local", "--get-regexp", "^remote\\."+regexp.QuoteMeta(remote)+"\\.") + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return RemoteConfigSnapshot{}, nil + } + if result.ExitCode != 0 { + return nil, &ExitError{Result: result} + } + snapshot := RemoteConfigSnapshot{} + for _, line := range strings.Split(strings.TrimSpace(result.Stdout), "\n") { + key, value, ok := strings.Cut(line, " ") + if ok { + snapshot[key] = append(snapshot[key], value) + } + } + return snapshot, nil +} + +func (g Git) RestoreRemoteConfig(ctx context.Context, remote string, snapshot RemoteConfigSnapshot) error { + result, err := g.Run(ctx, "config", "--local", "--remove-section", "remote."+remote) + if err != nil { + return err + } + if result.ExitCode != 0 && !strings.Contains(result.Stderr, "no such section") { + return &ExitError{Result: result} + } + keys := make([]string, 0, len(snapshot)) + for key := range snapshot { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range snapshot[key] { + if _, err := g.RunOK(ctx, "config", "--local", "--add", key, value); err != nil { + return err + } + } + } + return nil +} + func (g Git) CoreCommentChar(ctx context.Context) (string, bool, error) { result, err := g.RunOK(ctx, "config", "--get", "core.commentChar") if err != nil { @@ -472,6 +520,24 @@ func (g Git) LsTreeItem(ctx context.Context, treeish, path string) (TreeItem, er return item, nil } +func (g Git) TreeContainsGitlink(ctx context.Context, treeish, path string) (bool, error) { + args := []string{"ls-tree", "-r", treeish} + if path != "" { + args = append(args, "--", path) + } + out, err := g.Output(ctx, args...) + if err != nil { + return false, err + } + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && (fields[0] == "160000" || fields[1] == "commit") { + return true, nil + } + } + return false, nil +} + func (g Git) TreeItem(ctx context.Context, treeish string) (TreeItem, error) { hash, err := g.RevParse(ctx, treeish+"^{tree}") if err != nil { @@ -561,6 +627,20 @@ func (g Git) HashBytes(ctx context.Context, data []byte) (TreeItem, error) { return TreeItem{Mode: "100644", Type: "blob", Hash: strings.TrimSpace(result.Stdout)}, nil } +func (g Git) EmptyTree(ctx context.Context) (string, error) { + var stdout, stderr bytes.Buffer + result, err := g.Runner.RunInteractive(ctx, bytes.NewReader(nil), &stdout, &stderr, "hash-object", "-w", "-t", "tree", "--stdin") + result.Stdout = stdout.String() + result.Stderr = stderr.String() + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", &ExitError{Result: result} + } + return strings.TrimSpace(result.Stdout), nil +} + func (g Git) CommitMessage(ctx context.Context, message string) (bool, error) { result, err := g.Run(ctx, "commit", "--no-verify", "-m", message) if err != nil { @@ -612,6 +692,31 @@ func (g Git) UpdateRef(ctx context.Context, args ...string) error { return err } +func (g Git) UpdateRefsTransaction(ctx context.Context, refs map[string]string) error { + names := make([]string, 0, len(refs)) + for name := range refs { + names = append(names, name) + } + sort.Strings(names) + var input strings.Builder + input.WriteString("start\n") + for _, name := range names { + fmt.Fprintf(&input, "update %s %s\n", name, refs[name]) + } + input.WriteString("prepare\ncommit\n") + var stdout, stderr bytes.Buffer + result, err := g.Runner.RunInteractive(ctx, strings.NewReader(input.String()), &stdout, &stderr, "update-ref", "--stdin") + result.Stdout = stdout.String() + result.Stderr = stderr.String() + if err != nil { + return err + } + if result.ExitCode != 0 { + return &ExitError{Result: result} + } + return nil +} + func (g Git) ReadTreeUpdateMerge(ctx context.Context, treeish string) error { _, err := g.RunOK(ctx, "read-tree", "-um", treeish) return err @@ -774,6 +879,51 @@ func (g Git) MergeTreeWrite(ctx context.Context, baseTreeish, localTreeish, remo return parsed, nil } +func (g Git) ApplyConflictIndex(ctx context.Context, mergedTreeish, conflictIndex string, conflictPaths []string, paths ...string) error { + file, err := os.CreateTemp("", "braid-conflict-index-") + if err != nil { + return err + } + indexPath := file.Name() + if err := file.Close(); err != nil { + return err + } + _ = os.Remove(indexPath) + defer func() { _ = os.Remove(indexPath) }() + tempMerged := g.withIndex(indexPath) + if _, err := tempMerged.RunOK(ctx, "read-tree", mergedTreeish); err != nil { + return err + } + for _, conflictPath := range conflictPaths { + if _, err := tempMerged.RunOK(ctx, "update-index", "--force-remove", "--", conflictPath); err != nil { + return err + } + } + args := []string{"ls-files", "-s", "-z", "--"} + args = append(args, paths...) + entries, err := tempMerged.Output(ctx, args...) + if err != nil { + return err + } + entries += conflictIndex + for _, path := range paths { + if _, err := g.RunOK(ctx, "rm", "-r", "--cached", "--ignore-unmatch", "--", path); err != nil { + return err + } + } + var stdout, stderr bytes.Buffer + result, runErr := g.Runner.RunInteractive(ctx, strings.NewReader(entries), &stdout, &stderr, "update-index", "-z", "--index-info") + result.Stdout = stdout.String() + result.Stderr = stderr.String() + if runErr != nil { + return runErr + } + if result.ExitCode != 0 { + return &ExitError{Result: result} + } + return nil +} + func IsMergeTreeConflict(err error) bool { var exitErr *ExitError return errors.As(err, &exitErr) && exitErr.Result.ExitCode == 1 @@ -784,7 +934,7 @@ func mergeTreeWriteArgs(baseTreeish, localTreeish, remoteTreeish string, message if messages { messageArg = "--messages" } - return []string{"merge-tree", "--write-tree", "--name-only", "-z", messageArg, "--merge-base=" + baseTreeish, localTreeish, remoteTreeish} + return []string{"merge-tree", "--write-tree", "-z", messageArg, "--merge-base=" + baseTreeish, localTreeish, remoteTreeish} } func (g Git) mergeTreeConflictPathFallback(ctx context.Context, parsed MergeTreeResult, baseTreeish, localTreeish, remoteTreeish string) MergeTreeResult { @@ -971,11 +1121,16 @@ func parseMergeTreeOutputZ(output string) MergeTreeResult { i := 1 for ; i < len(records); i++ { - path := records[i] - if path == "" { + record := records[i] + if record == "" { i++ break } + path := record + if _, parsedPath, ok := strings.Cut(record, "\t"); ok { + path = parsedPath + result.ConflictIndex += record + "\x00" + } if !seenPaths[path] { seenPaths[path] = true result.ConflictPaths = append(result.ConflictPaths, path) diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index 9366796..f79ebcc 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -975,15 +975,15 @@ func TestMergeTreeWriteConflictWithEmptyStructuredPathSectionUsesMessagePaths(t func TestCommitTreeWithTemporaryIndexExcludesRealIndexAndPreservesHookBehavior(t *testing.T) { repo := initRealRepo(t) - writeRealFile(t, repo, "mirror.txt", "base\n") + writeRealFile(t, repo, "source.txt", "base\n") writeRealFile(t, repo, "unrelated.txt", "base\n") realGit(t, repo, "add", ".") realGit(t, repo, "commit", "-m", "base") - writeRealFile(t, repo, "mirror.txt", "updated\n") - blob := realGitOutput(t, repo, "hash-object", "-w", "mirror.txt") + writeRealFile(t, repo, "source.txt", "updated\n") + blob := realGitOutput(t, repo, "hash-object", "-w", "source.txt") git := New(repo, false, nil) - tree, err := git.MakeTreeWithItemIn(context.Background(), "HEAD", "mirror.txt", TreeItem{Mode: "100644", Type: "blob", Hash: blob}) + tree, err := git.MakeTreeWithItemIn(context.Background(), "HEAD", "source.txt", TreeItem{Mode: "100644", Type: "blob", Hash: blob}) if err != nil { t.Fatalf("MakeTreeWithItemIn returned error: %v", err) } @@ -1002,8 +1002,8 @@ func TestCommitTreeWithTemporaryIndexExcludesRealIndexAndPreservesHookBehavior(t if !committed { t.Fatal("CommitTreeWithTemporaryIndex committed = false, want true") } - if got := realGitOutput(t, repo, "show", "HEAD:mirror.txt"); got != "updated" { - t.Fatalf("HEAD:mirror.txt = %q, want updated", got) + if got := realGitOutput(t, repo, "show", "HEAD:source.txt"); got != "updated" { + t.Fatalf("HEAD:source.txt = %q, want updated", got) } if got := realGitOutput(t, repo, "show", "HEAD:unrelated.txt"); got != "base" { t.Fatalf("HEAD:unrelated.txt = %q, want base", got) @@ -1011,7 +1011,7 @@ func TestCommitTreeWithTemporaryIndexExcludesRealIndexAndPreservesHookBehavior(t if _, err := os.Stat(filepath.Join(repo, "post-commit-ran")); err != nil { t.Fatalf("post-commit hook did not run: %v", err) } - if err := git.RestorePathspecsFromHead(context.Background(), "mirror.txt"); err != nil { + if err := git.RestorePathspecsFromHead(context.Background(), "source.txt"); err != nil { t.Fatalf("RestorePathspecsFromHead returned error: %v", err) } if staged := realGitOutput(t, repo, "diff", "--cached", "--name-only"); staged != "unrelated.txt" { diff --git a/internal/mirror/BUILD.bazel b/internal/mirror/BUILD.bazel deleted file mode 100644 index 7b6f971..0000000 --- a/internal/mirror/BUILD.bazel +++ /dev/null @@ -1,15 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "mirror", - srcs = ["mirror.go"], - importpath = "braid/internal/mirror", - visibility = ["//visibility:public"], -) - -go_test( - name = "mirror_test", - srcs = ["mirror_test.go"], - embed = [":mirror"], - timeout = "short", -) diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go deleted file mode 100644 index c21f1aa..0000000 --- a/internal/mirror/mirror.go +++ /dev/null @@ -1,111 +0,0 @@ -package mirror - -import ( - "errors" - "path" - "regexp" - "strings" -) - -var remoteUnsafeChars = regexp.MustCompile(`[^-A-Za-z0-9]`) - -type Options struct { - LocalPath string - Branch string - Tag string - Revision string - RemotePath string - PartialClone bool -} - -type Mirror struct { - Path string - URL string - Branch string - RemotePath string - Tag string - Revision string - PartialClone bool -} - -func NewFromOptions(url string, options Options) (Mirror, error) { - if options.Tag != "" && options.Branch != "" { - return Mirror{}, errors.New("cannot specify both tag and branch") - } - if options.Tag != "" && options.Revision != "" { - return Mirror{}, errors.New("cannot specify both tag and revision") - } - if options.PartialClone && options.RemotePath == "" { - return Mirror{}, errors.New("partial clone requires path") - } - - cleanURL := strings.TrimRight(url, "/") - localPath := cleanMirrorPath(options.LocalPath) - if localPath == "" { - localPath = defaultLocalPath(cleanURL, options.RemotePath) - } - - return Mirror{ - Path: localPath, - URL: cleanURL, - Branch: options.Branch, - RemotePath: strings.TrimRight(options.RemotePath, "/"), - Tag: options.Tag, - Revision: options.Revision, - PartialClone: options.PartialClone, - }, nil -} - -func (m Mirror) Locked() bool { - return m.Branch == "" && m.Tag == "" -} - -func (m Mirror) TrackingName() string { - switch { - case m.Branch != "": - return m.Branch - case m.Tag != "": - return m.Tag - default: - return "revision" - } -} - -func (m Mirror) Remote() string { - return remoteUnsafeChars.ReplaceAllString(m.TrackingName()+"_braid_"+m.Path, "_") -} - -func (m Mirror) LocalRef() string { - switch { - case m.Branch != "": - return m.Remote() + "/" + m.Branch - case m.Tag != "": - return "refs/remotes/" + m.Remote() + "/tags/" + m.Tag - default: - return m.Revision - } -} - -func (m Mirror) RemoteRef() (string, error) { - switch { - case m.Branch != "": - return "+refs/heads/" + m.Branch, nil - case m.Tag != "": - return "+refs/tags/" + m.Tag, nil - default: - return "", errors.New("revision-locked mirror has no remote ref") - } -} - -func defaultLocalPath(url, remotePath string) string { - if remotePath != "" { - return path.Base(strings.TrimRight(remotePath, "/")) - } - trimmed := strings.TrimRight(url, `/\`) - name := path.Base(strings.ReplaceAll(trimmed, `\`, "/")) - return strings.TrimSuffix(name, ".git") -} - -func cleanMirrorPath(value string) string { - return strings.TrimRight(strings.ReplaceAll(value, `\`, "/"), "/") -} diff --git a/internal/mirror/mirror_test.go b/internal/mirror/mirror_test.go deleted file mode 100644 index 06e3bc3..0000000 --- a/internal/mirror/mirror_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package mirror - -import "testing" - -func TestNewFromOptionsDefaultsPath(t *testing.T) { - tests := []struct { - name string - url string - remotePath string - want string - }{ - {name: "url basename", url: "http://example.test/path.git", want: "path"}, - {name: "trailing slash", url: "http://example.test/path.git/", want: "path"}, - {name: "windows local path", url: `C:\Users\peter\repo.git`, want: "repo"}, - {name: "windows local path trailing separator", url: `C:\Users\peter\repo.git\`, want: "repo"}, - {name: "windows local path without suffix", url: `C:\Users\peter\repo`, want: "repo"}, - {name: "windows slash local path", url: "C:/Users/peter/repo.git", want: "repo"}, - {name: "unc local path", url: `\\server\share\repo.git`, want: "repo"}, - {name: "remote path basename", url: "http://example.test/repo.git", remotePath: "lib/component", want: "component"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := NewFromOptions(test.url, Options{RemotePath: test.remotePath}) - if err != nil { - t.Fatalf("NewFromOptions returned error: %v", err) - } - if got.Path != test.want { - t.Fatalf("Path = %q, want %q", got.Path, test.want) - } - }) - } -} - -func TestNewFromOptionsStripsSpecifiedPath(t *testing.T) { - got, err := NewFromOptions("http://example.test/path.git", Options{LocalPath: `vendor\tools\mytool\`}) - if err != nil { - t.Fatalf("NewFromOptions returned error: %v", err) - } - if got.Path != "vendor/tools/mytool" { - t.Fatalf("Path = %q, want vendor/tools/mytool", got.Path) - } -} - -func TestMirrorRefsAndRemote(t *testing.T) { - branch, err := NewFromOptions("http://example.test/mytool.git", Options{Branch: "mybranch"}) - if err != nil { - t.Fatalf("NewFromOptions branch returned error: %v", err) - } - if got, want := branch.Remote(), "mybranch_braid_mytool"; got != want { - t.Fatalf("Remote = %q, want %q", got, want) - } - if got, want := branch.LocalRef(), "mybranch_braid_mytool/mybranch"; got != want { - t.Fatalf("LocalRef = %q, want %q", got, want) - } - if got, err := branch.RemoteRef(); err != nil || got != "+refs/heads/mybranch" { - t.Fatalf("RemoteRef = %q, %v; want +refs/heads/mybranch, nil", got, err) - } - - tag, err := NewFromOptions("http://example.test/mytool.git", Options{Tag: "v1"}) - if err != nil { - t.Fatalf("NewFromOptions tag returned error: %v", err) - } - if got, want := tag.Remote(), "v1_braid_mytool"; got != want { - t.Fatalf("Remote = %q, want %q", got, want) - } - if got, want := tag.LocalRef(), "refs/remotes/v1_braid_mytool/tags/v1"; got != want { - t.Fatalf("LocalRef = %q, want %q", got, want) - } - if got, err := tag.RemoteRef(); err != nil || got != "+refs/tags/v1" { - t.Fatalf("RemoteRef = %q, %v; want +refs/tags/v1, nil", got, err) - } -} - -func TestRemoteSanitizesUnsafePathChars(t *testing.T) { - got, err := NewFromOptions("http://example.test/path.git", Options{ - LocalPath: ".dotfolder/.dotfile.ext", - Branch: "master", - }) - if err != nil { - t.Fatalf("NewFromOptions returned error: %v", err) - } - if got.Remote() != "master_braid__dotfolder__dotfile_ext" { - t.Fatalf("Remote = %q", got.Remote()) - } -} - -func TestRevisionLockedMirror(t *testing.T) { - got, err := NewFromOptions("http://example.test/path.git", Options{Revision: "abc123"}) - if err != nil { - t.Fatalf("NewFromOptions returned error: %v", err) - } - if !got.Locked() { - t.Fatal("Locked = false, want true") - } - if got.LocalRef() != "abc123" { - t.Fatalf("LocalRef = %q, want abc123", got.LocalRef()) - } - if _, err := got.RemoteRef(); err == nil { - t.Fatal("RemoteRef returned nil error for revision-locked mirror") - } -} - -func TestNewFromOptionsRejectsInvalidTrackingCombinations(t *testing.T) { - if _, err := NewFromOptions("http://example.test/path.git", Options{Branch: "main", Tag: "v1"}); err == nil { - t.Fatal("branch+tag returned nil error") - } - if _, err := NewFromOptions("http://example.test/path.git", Options{Revision: "abc123", Tag: "v1"}); err == nil { - t.Fatal("revision+tag returned nil error") - } -} diff --git a/internal/pathcheck/BUILD.bazel b/internal/pathcheck/BUILD.bazel index d0424e4..3dc1680 100644 --- a/internal/pathcheck/BUILD.bazel +++ b/internal/pathcheck/BUILD.bazel @@ -5,7 +5,7 @@ go_library( srcs = ["pathcheck.go"], importpath = "braid/internal/pathcheck", visibility = ["//visibility:public"], - deps = ["//internal/mirror"], + deps = ["//internal/source"], ) go_test( @@ -13,5 +13,5 @@ go_test( srcs = ["pathcheck_test.go"], embed = [":pathcheck"], timeout = "short", - deps = ["//internal/mirror"], + deps = ["//internal/source"], ) diff --git a/internal/pathcheck/pathcheck.go b/internal/pathcheck/pathcheck.go index 3f30e55..f384cdc 100644 --- a/internal/pathcheck/pathcheck.go +++ b/internal/pathcheck/pathcheck.go @@ -6,7 +6,7 @@ import ( "strings" "unicode" - "braid/internal/mirror" + "braid/internal/source" ) type Error struct { @@ -27,8 +27,11 @@ func ValidateLocal(localPath string, existing []string) error { } clean := cleanSlash(localPath) for _, existingPath := range existing { - if strings.EqualFold(clean, cleanSlash(existingPath)) { - return &Error{Path: localPath, Reason: "case-fold collision with existing mirror path " + existingPath} + existingClean := cleanSlash(existingPath) + folded := strings.ToLower(clean) + existingFolded := strings.ToLower(existingClean) + if folded == existingFolded || strings.HasPrefix(folded, existingFolded+"/") || strings.HasPrefix(existingFolded, folded+"/") { + return &Error{Path: localPath, Reason: "case-fold collision or overlap with existing mirror path " + existingPath} } } return nil @@ -38,11 +41,11 @@ func ValidateUpstream(upstreamPath string) error { return validatePortable(upstreamPath, false) } -func CheckRemoteCollision(candidate mirror.Mirror, existing []mirror.Mirror) error { +func CheckRemoteCollision(candidate source.Source, existing []source.Source) error { candidateRemote := candidate.Remote() - for _, existingMirror := range existing { - if candidateRemote == existingMirror.Remote() { - return fmt.Errorf("remote name collision: %q for mirror paths %q and %q", candidateRemote, candidate.Path, existingMirror.Path) + for _, existingSource := range existing { + if candidateRemote == existingSource.Remote() { + return fmt.Errorf("remote name collision: %q for sources %q and %q", candidateRemote, candidate.Name, existingSource.Name) } } return nil diff --git a/internal/pathcheck/pathcheck_test.go b/internal/pathcheck/pathcheck_test.go index 7c4a764..1a23f31 100644 --- a/internal/pathcheck/pathcheck_test.go +++ b/internal/pathcheck/pathcheck_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "braid/internal/mirror" + "braid/internal/source" ) func TestValidateLocalPathContract(t *testing.T) { @@ -98,8 +98,8 @@ func TestCaseFoldCollision(t *testing.T) { } func TestRemoteNameCollision(t *testing.T) { - existing := []mirror.Mirror{{Path: "a.b", Branch: "main"}} - candidate := mirror.Mirror{Path: "a_b", Branch: "main"} + existing := []source.Source{{Name: "a.b", Tracking: source.BranchTracking{Branch: "main"}}} + candidate := source.Source{Name: "a_b", Tracking: source.BranchTracking{Branch: "main"}} err := CheckRemoteCollision(candidate, existing) if err == nil { diff --git a/internal/source/BUILD.bazel b/internal/source/BUILD.bazel new file mode 100644 index 0000000..647cd55 --- /dev/null +++ b/internal/source/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "source", + srcs = ["source.go"], + importpath = "braid/internal/source", + visibility = ["//visibility:public"], +) + +go_test( + name = "source_test", + srcs = ["source_test.go"], + embed = [":source"], + timeout = "short", +) diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..986791a --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,163 @@ +package source + +import ( + "errors" + "path" + "regexp" + "sort" + "strings" +) + +var remoteUnsafeChars = regexp.MustCompile(`[^-A-Za-z0-9]`) +var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +type Tracking interface { + isTracking() + Name() string +} + +type BranchTracking struct{ Branch string } + +func (BranchTracking) isTracking() {} +func (t BranchTracking) Name() string { return t.Branch } + +type TagTracking struct{ Tag string } + +func (TagTracking) isTracking() {} +func (t TagTracking) Name() string { return t.Tag } + +type RevisionTracking struct{} + +func (RevisionTracking) isTracking() {} +func (RevisionTracking) Name() string { return "revision" } + +type Mirror struct { + LocalPath string + UpstreamPath string +} + +type Source struct { + Name string + URL string + Tracking Tracking + Revision string + PartialClone bool + Mirrors []Mirror +} + +type SourceMirror struct { + Source + Mirror +} + +type SourceSelection struct { + Source Source + Mirrors []Mirror +} + +func (s Source) Locked() bool { _, ok := s.Tracking.(RevisionTracking); return ok } +func (s Source) TrackingName() string { + if s.Tracking == nil { + return "revision" + } + return s.Tracking.Name() +} +func (s Source) TrackingIdentity() string { + switch t := s.Tracking.(type) { + case BranchTracking: + return "branch:" + t.Branch + case TagTracking: + return "tag:" + t.Tag + default: + return "revision" + } +} +func (s Source) Branch() string { + if t, ok := s.Tracking.(BranchTracking); ok { + return t.Branch + } + return "" +} +func (s Source) Tag() string { + if t, ok := s.Tracking.(TagTracking); ok { + return t.Tag + } + return "" +} +func (s Source) Remote() string { + return remoteUnsafeChars.ReplaceAllString(s.TrackingName()+"_braid_"+s.Name, "_") +} +func (s Source) LocalRef() string { + switch t := s.Tracking.(type) { + case BranchTracking: + return s.Remote() + "/" + t.Branch + case TagTracking: + return "refs/remotes/" + s.Remote() + "/tags/" + t.Tag + default: + return s.Revision + } +} +func (s Source) RemoteRef() (string, error) { + switch t := s.Tracking.(type) { + case BranchTracking: + return "+refs/heads/" + t.Branch, nil + case TagTracking: + return "+refs/tags/" + t.Tag, nil + default: + return "", errors.New("revision-locked source has no remote ref") + } +} +func (s Source) SortedMirrors() []Mirror { + result := append([]Mirror(nil), s.Mirrors...) + sort.Slice(result, func(i, j int) bool { return result[i].LocalPath < result[j].LocalPath }) + return result +} +func (s Source) MirrorByLocalPath(localPath string) (Mirror, bool) { + for _, m := range s.Mirrors { + if m.LocalPath == localPath { + return m, true + } + } + return Mirror{}, false +} +func (s Source) LocalPaths() []string { + result := make([]string, 0, len(s.Mirrors)) + for _, m := range s.SortedMirrors() { + result = append(result, m.LocalPath) + } + return result +} +func (s Source) WithMirror(m Mirror) SourceMirror { return SourceMirror{Source: s, Mirror: m} } +func (m SourceMirror) Remote() string { return m.Source.Remote() } +func (m SourceMirror) LocalRef() string { return m.Source.LocalRef() } +func (m SourceMirror) RemoteRef() (string, error) { return m.Source.RemoteRef() } +func (m SourceMirror) Locked() bool { return m.Source.Locked() } +func (m SourceMirror) TrackingName() string { return m.Source.TrackingName() } +func (m SourceMirror) TrackingIdentity() string { return m.Source.TrackingIdentity() } + +func CleanURL(value string) string { + if value == "" || value == "/" || value == `\` || isWindowsRoot(value) || strings.EqualFold(value, "file:///") { + return value + } + trimmed := strings.TrimRight(value, `/\`) + if trimmed == "" { + return value + } + return trimmed +} +func URLIdentity(value string) string { return CleanURL(value) } +func DerivedName(url string) string { + cleaned := CleanURL(url) + name := path.Base(strings.ReplaceAll(cleaned, `\`, "/")) + return strings.TrimSuffix(name, ".git") +} +func ValidName(name string) bool { return name != "." && name != ".." && namePattern.MatchString(name) } +func isWindowsRoot(value string) bool { + if len(value) < 3 { + return false + } + if !strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", rune(value[0])) || value[1] != ':' { + return false + } + return strings.Trim(value[2:], `/\`) == "" +} diff --git a/internal/source/source_test.go b/internal/source/source_test.go new file mode 100644 index 0000000..cfd25c1 --- /dev/null +++ b/internal/source/source_test.go @@ -0,0 +1,27 @@ +package source + +import "testing" + +func TestCleanURLPreservesRoots(t *testing.T) { + for _, value := range []string{"/", `C:\`, "file:///"} { + if got := CleanURL(value); got != value { + t.Fatalf("CleanURL(%q)=%q", value, got) + } + } +} +func TestDerivedName(t *testing.T) { + for _, test := range []struct{ url, want string }{{"https://example.test/repo.git/", "repo"}, {`C:\work\repo.git`, "repo"}, {"git@example.test:org/repo.git", "repo"}} { + if got := DerivedName(test.url); got != test.want { + t.Fatalf("DerivedName(%q)=%q want %q", test.url, got, test.want) + } + } +} +func TestSourceRefs(t *testing.T) { + s := Source{Name: "repo", Tracking: BranchTracking{Branch: "main"}} + if got := s.Remote(); got != "main_braid_repo" { + t.Fatal(got) + } + if got := s.LocalRef(); got != "main_braid_repo/main" { + t.Fatal(got) + } +}