diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e68562c7..1ce6cde49 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + UV_PYTHON: ${{ matrix.python-version }} strategy: matrix: python-version: ['3.10', '3.14'] @@ -27,8 +29,17 @@ jobs: - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} + - name: Install native VCS tools + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends git mercurial subversion + git --version + hg --version + svn --version --quiet + svnadmin --version --quiet + - name: Install dependencies - run: uv sync --all-extras --dev + run: uv sync --frozen --all-extras --dev - name: Lint with ruff check run: uv run ruff check . @@ -43,9 +54,10 @@ jobs: run: | python -V uv run python -V + uv run python -c 'import os, sys; assert ".".join(map(str, sys.version_info[:2])) == os.environ["UV_PYTHON"]' - name: Test with pytest - run: uv run py.test --cov=./ --cov-append --cov-report=xml + run: uv run py.test --no-showlocals --reruns 0 -ra -n 2 --dist worksteal --cov=./ --cov-append --cov-report=xml env: COV_CORE_SOURCE: . COV_CORE_CONFIG: .coveragerc diff --git a/CHANGES b/CHANGES index 7865960eb..c47a3d304 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,177 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### What's new + +#### Create linked Git worktrees under shared ownership + +`GitSync.create_worktree()` fetches and creates an absent linked checkout at a +typed target while holding the repository ownership lock. It retains local +commits, rejects divergence and occupied destinations, and supports detached +checkouts and worktree lock reasons. Interrupted recovery records refuse +creation with their retained token; parent working files remain untouched. + + +`GitSync.update_repo(detach=True)` detaches at the resolved target after +fetching under the ownership lock. Branch targets retain their native +tracking-ref and local-branch fallback rules. + +Progress callback exceptions and interrupts terminate and reap the direct +subprocess before propagating. Direct calls own the immediate child; callers +that need descendant cancellation must supervise an owned process group. + +#### Subversion retains complete working copies for recovery + +`SvnSync.update_repo()` aborts on dirty checkouts by default. Explicit +preservation uses native update or switch and retains a complete working-copy +copy, including schedules, binary properties, unknown files, and ignored files. +Missing paths stay absent only when their complete native and physical scope +is unchanged. Conflicts and failed inspection retain their recovery token. +`recover_changes()` creates independent copies offline; `list_recoveries()` +reports interrupted operations, and `release_changes()` releases verified copies. +Recovery and release work after the original checkout is deleted or replaced. + +SVN targets accept `rev`; the configured repository URL controls switching. +Branch, tag, commit, and remote selectors are rejected. Preservation requires +POSIX and SVN working-copy format 31. It rejects externals, nested repositories, +and linked administrative storage. Callers must stop external editors and native +writers. + +#### Mercurial remotes keep fetch and push separate + +`create_project(vcs="hg", remotes=...)` forwards configured Mercurial paths +through the same factory used by configuration consumers. + +`HgRemote` and `HgSync(remotes=...)` expose native `[paths]` aliases with +separate fetch and push URLs. `SyncTarget(remote=...)` chooses where to pull +without changing unrelated push destinations. Configured aliases update +atomically during following syncs; keep, warn, and dirty-abort preflight leave +native configuration unchanged. Comments and includes survive updates, and +equivalent relative or environment-expanded paths do not rewrite the file. +See {doc}`sync/hg`. + +#### Shared checkout targets and recovery outcomes + +`SyncTarget` selects one branch, tag, commit, or revision. `SyncPolicy` keeps +configured-target drift separate from local-change handling and defaults to +`dirty="abort"`. `SyncResult` now reports update and preservation outcomes, +conflicts, and retained recovery tokens independently. + +#### Mercurial retains shelves for independent recovery + +`HgSync.update_repo()` aborts on dirty checkouts by default. Explicit +`SyncPolicy(dirty="preserve")` retains native shelves in private storage, +including added, removed, modified, and unknown files. Missing files retain +their unscheduled state; upstream changes to a missing path report a conflict. +Native unshelve conflicts keep their recovery token and caller-owned backups. +`recover_changes()` restores the original branch, bookmark, and native status +in a separate checkout using retained local history, without remote access. +`list_recoveries()` exposes interrupted saves and damaged records; +`release_changes()` removes only verified owned shelf artifacts. + +#### Mercurial clone applies transport and revision selection + +`HgSync` strips the `hg+` adapter prefix before invoking Mercurial and applies +the constructor's `rev` when checking out a new clone. +An explicit `update_repo(target=...)` overrides that revision. A new checkout +starts at its configured target before keep or warn policy applies. + +#### Git retains dirty changes for independent recovery + +`GitSync.update_repo()` aborts on dirty checkouts by default. Explicit +`SyncPolicy(dirty="preserve")` retains staged, unstaged, and untracked changes +in an owned stash and applies them with the index after a fast-forward update. +Conflicts and failed updates retain a recovery token. `list_recoveries()` +finds interrupted saves; `recover_changes()` creates a separate original-base +checkout from local Git objects; `release_changes()` removes owned material. +Recovery requires those local objects and never fetches from the remote. +Malformed recovery records remain visible as errors carrying their token. +Ignored target collisions, divergent commits, active native operations, +dirty submodule scopes, and nested repositories fail before capture. +Clean recursive submodule updates reject ignored-file collisions in initialized +children before any checkout advances. Target resolution refreshes every +configured remote; clone and fetch honor command-scoped TLS configuration. + +New Git checkouts follow the configured target before keep or warn policy +applies. Those policies continue to leave existing checkouts unchanged. + +#### Git command configuration precedes subcommands + +{meth}`~libvcs.cmd.git.Git.run` places global flags before the subcommand and +uses native `-c` arguments for command-scoped configuration. Sync operations +with `GitOptions(tls_verify=False)` now run clone, fetch, and submodule commands +without misreading the TLS setting as a repository argument. + +#### Sync backends expose typed options + +{class}`~libvcs.sync.git.GitOptions`, {class}`~libvcs.sync.hg.HgOptions`, and +{class}`~libvcs.sync.svn.SvnOptions` are frozen backend configuration objects. +Each sync class exposes its option class as `options_type` for dataclass +introspection. Constructors now reject unknown fields and option objects from +another backend. + +Mercurial and Subversion string options reject NUL before command creation. +Git filter normalization preserves the nesting limit accepted by validation, +including when the immutable options object is passed to clone. + +Git depth and filter settings apply to the initial clone and its submodules. +Submodule initialization follows nested gitlinks and forwards the history depth. +Git and Mercurial verify TLS certificates by default; setting `tls_verify` to +false forwards the native verification override to network operations. +Mercurial clone transport settings and Subversion authentication, certificate, +external, and ambient-depth settings now reach their native commands. + +#### Typed Git filters validate partial clones before execution + +{mod}`libvcs.cmd.git_filter` provides frozen models for Git's `blob:none`, +`blob:limit`, `tree`, `object:type`, `sparse:oid`, `auto`, and `combine` +filters. Specs and kind-tagged mappings parse into the same models, with +validation for numeric ranges, combine escaping, recursion, incompatible +`auto` filters, and field names. + +{meth}`~libvcs.cmd.git.Git.clone`, {meth}`~libvcs.cmd.git.Git.fetch`, and +{meth}`~libvcs.cmd.git.GitSubmoduleCmd.update` accept models, strings, +mappings, and sequences through `_filter`. Clone and fetch preserve repeated +filter arguments; submodule update combines them so Git applies every filter. +{meth}`~libvcs.cmd.git.Git.pull` rejects filters because native Git does not +support them. + +{class}`~libvcs.sync.git.GitOptions` accepts the same values through `filter`, +validates them before creating a checkout, and applies them to the initial +clone and its submodules. `Auto` can clone a repository without gitlinks. If +the cloned index contains gitlinks, obtain reports that `Auto` cannot be +forwarded and retains the parent clone. + +#### Inspect working-copy positions + +`GitSync.get_position()`, `HgSync.get_position()`, and +`SvnSync.get_position()` report the checkout's local revision and ref without +contacting a remote. Subversion also reports mixed revisions and switched +subtrees. See {doc}`sync/index` for an example. + +### Fixes + +#### Drain command output while the process runs + +Commands with large stdout or stderr output complete without filling an +unread pipe. Both streams drain concurrently with or without a timeout, +and progress callbacks retain command diagnostics for failure reporting. + +#### Keep partial-clone remotes usable + +Git remote listing retains fetch URLs annotated with a partial-clone filter. +A failed `GitSync.obtain()` reports the clone command and Git's original +message before attempting submodule or remote setup. + +#### Keep fetch and push destinations separate + +`GitSync` configures a remote's push URL without replacing its fetch URL. + +#### Keep Subversion options before command targets + +`Svn.run()` places global options before command arguments, so an explicit +`--` separator does not turn `--non-interactive` into a filename. + ## libvcs 0.46.0 (2026-08-30) libvcs 0.46.0 hardens the command wrappers against argument injection: `Git.pull()`, a `GitSync` revision, and `Svn.checkout()` now reject a value a VCS binary would parse as an option in the positions an end-of-options `--` cannot guard, closing paths that could otherwise run a command or truncate a file. It also gives progress-callback timestamps an aware UTC value, documents what every field holds in the API reference, and moves the lint and CI toolchain to current majors. diff --git a/MIGRATION b/MIGRATION index 7881be1d2..8302a13a4 100644 --- a/MIGRATION +++ b/MIGRATION @@ -24,6 +24,93 @@ _Notes on the upcoming release will be added here_ +### Sync results and dirty checkout policy + +`update_repo()` creates or updates a checkout and returns `SyncResult`. +Check `result.ok` and its ordered `errors` before proceeding. `obtain()` only +creates the initial checkout. + +The default is now `SyncPolicy(drift="follow", dirty="abort")`. Automation +that updates dirty checkouts must choose `dirty="preserve"` to retain +recoverable changes or explicitly choose `dirty="discard"` to remove them. +The library does not prompt for discard. Preservation may report conflicts; +it never falls back to discard. + +Pass `target=SyncTarget(...)` and `policy=SyncPolicy(...)` to `update_repo()`. +A method target replaces the constructor's `rev` default. Existing Git/Hg +keep/warn operations do not fetch or change attachment; new checkouts first +establish their configured target. SVN keep/warn requires a numeric revision +for local comparison. + +Retain `result.recovery` on both successful and failed updates. Tokens stay +available until `release_changes()`; `list_recoveries()` discovers them and +`recover_changes()` writes a separate new checkout. See the executing +{ref}`recovery example ` and backend limits before deleting +source repositories or recovery storage. + +### Mercurial remotes and selected pull sources + +Use `HgSync(remotes={"upstream": HgRemote("upstream", fetch_url, push_url)})` +to declare native Mercurial paths. A string value sets both destinations to +that URL. Pass `SyncTarget(branch="default", remote="upstream")` to choose +the pull source independently of the push URL. + +Following updates apply configured paths. On existing checkouts, keep/warn +policies and dirty-abort preflight leave `.hg/hgrc` unchanged. The library owns a final marked `[paths]` +block; comments and includes outside it remain intact. Leave that block last +when editing native configuration manually. + +### Sync backends use typed options + +Backend-specific constructor arguments move into frozen option objects. Import +the option class from `libvcs` or beside its sync class: + +```python +>>> from libvcs import GitOptions, GitSync, HgOptions, SvnOptions +>>> git = GitSync( +... url="https://example.com/project.git", +... path=tmp_path / "project", +... options=GitOptions(depth=1, tls_verify=True), +... ) +>>> git.options +GitOptions(depth=1, filter=None, tls_verify=True) +``` + +Replace the released Git constructor arguments as follows: + +- `git_shallow=True` becomes `options=GitOptions(depth=1)`. +- `depth=n` becomes `options=GitOptions(depth=n)`. +- `git_filter=value` becomes `options=GitOptions(filter=value)`. +- The old `tls_verify=True` disabled verification. Use + `options=GitOptions(tls_verify=False)` to retain that behavior. The new + default, `tls_verify=True`, verifies certificates. + +Mercurial transport settings belong to `HgOptions`. Subversion `username`, +`password`, and `svn_trust_cert` become `SvnOptions(username=...)`, +`SvnOptions(password=...)`, and `SvnOptions(trust_server_cert=...)`. +Constructors and `create_project()` reject unknown keywords instead of +silently retaining or discarding them. Use `dataclasses.fields(Sync.options_type)` +to enumerate a backend's accepted configuration fields. + +### Git partial-clone filters have typed models + +String `_filter` arguments remain valid. Callers that build filters from +configuration can replace unchecked strings with models or kind-tagged +mappings from `libvcs.cmd.git_filter`: + +```python +>>> from libvcs.cmd.git_filter import BlobLimit +>>> BlobLimit("4m") +BlobLimit(limit='4m') +``` + +`GitOptions(filter=...)` validates the value during construction, applies it +to the initial clone and its submodules, and leaves existing checkout filters +unchanged during updates. `Auto` requires Git 2.54 or newer. If the cloned +repository contains gitlinks, `GitSync` retains the parent clone and raises +before submodule initialization because Git's submodule command rejects +`auto`. Use an explicit filter for repositories with submodules. + ### pytest fixtures: `gitconfig` / `hgconfig` renamed to `vcs_gitconfig` / `vcs_hgconfig` (#528) - pytest: `gitconfig` renamed to `vcs_gitconfig` @@ -69,17 +156,21 @@ references these fixtures. `URL.rule_map` is now a class attribute rather than a dataclass attribute. -Before Python 3.11 rejected mutable dataclass defaults: +Python 3.11 and newer reject a `RuleMap` instance as a mutable dataclass +default. Python 3.10 accepts the field: ```python >>> import dataclasses +>>> import sys >>> from libvcs.url.base import RuleMap ->>> @dataclasses.dataclass(repr=False) -... class GitLabURL: -... rule_map: RuleMap = RuleMap(_rule_map={}) -Traceback (most recent call last): -... -ValueError: mutable default for field rule_map is not allowed: use default_factory +>>> try: +... @dataclasses.dataclass(repr=False) +... class GitLabURL: +... rule_map: RuleMap = RuleMap(_rule_map={}) +... except ValueError as error: +... assert "mutable default" in str(error) +... else: +... assert sys.version_info < (3, 11) ``` After release: diff --git a/docs/cmd/git/filter.md b/docs/cmd/git/filter.md new file mode 100644 index 000000000..3fdd59f38 --- /dev/null +++ b/docs/cmd/git/filter.md @@ -0,0 +1,78 @@ +(git-partial-clone-filters)= + +# Partial-clone filters + +{mod}`libvcs.cmd.git_filter` validates Git object filters before a command +creates a checkout or starts a process. The frozen models cover Git's current +filter grammar: + +| Model | Git specification | Meaning | +| --- | --- | --- | +| {class}`~libvcs.cmd.git_filter.BlobNone` | `blob:none` | Omit blobs until Git needs them | +| {class}`~libvcs.cmd.git_filter.BlobLimit` | `blob:limit=n[KMG]` | Omit blobs at or above the byte limit | +| {class}`~libvcs.cmd.git_filter.TreeDepth` | `tree:n` | Omit trees and blobs at depth `n` or deeper | +| {class}`~libvcs.cmd.git_filter.ObjectType` | `object:type=TYPE` | Include one of `blob`, `tree`, `commit`, or `tag` | +| {class}`~libvcs.cmd.git_filter.SparseOid` | `sparse:oid=OID` | Read sparse patterns from an object | +| {class}`~libvcs.cmd.git_filter.Auto` | `auto` | Ask Git to choose a server-recommended filter | +| {class}`~libvcs.cmd.git_filter.Combine` | `combine:FILTER+FILTER` | Apply every encoded child filter | + +Build a model directly, parse a Git specification, or coerce a kind-tagged +mapping: + +```python +>>> from libvcs.cmd.git_filter import BlobLimit, coerce_filter, parse_filter +>>> BlobLimit("4m") +BlobLimit(limit='4m') +>>> parse_filter("tree:2") +TreeDepth(depth=2) +>>> coerce_filter({"kind": "object:type", "type": "commit"}) +ObjectType(type='commit') +``` + +{func}`~libvcs.cmd.git_filter.coerce_filter` turns a nonempty sequence into a +{class}`~libvcs.cmd.git_filter.Combine`. Clone and fetch preserve a top-level +sequence as repeated flags. Submodule update emits one equivalent `combine:` +specification because Git otherwise keeps only the last repeated filter. + +```python +>>> from libvcs.cmd.git_filter import ( +... BlobNone, +... TreeDepth, +... coerce_filter, +... filter_specs, +... ) +>>> coerce_filter([BlobNone(), TreeDepth(1)]) +Combine(filters=(BlobNone(), TreeDepth(depth=1))) +>>> filter_specs([BlobNone(), TreeDepth(1)]) +('blob:none', 'tree:1') +``` + +Pass the same accepted values to `_filter` on +{meth}`~libvcs.cmd.git.Git.clone`, {meth}`~libvcs.cmd.git.Git.fetch`, or +{meth}`~libvcs.cmd.git.GitSubmoduleCmd.update`. Existing string values remain +valid. Native `git pull` has no filter option, so +{meth}`~libvcs.cmd.git.Git.pull` rejects every nonempty filter. Call +{meth}`~libvcs.cmd.git.Git.fetch` with the filter before an unfiltered pull. + +## Validation limits + +Explicit integer values range from zero through `18446744073709551615`. +Textual values use Git's unsigned-long syntax: decimal, leading-zero octal, or +`0x` hexadecimal with an optional leading plus sign. A case-insensitive `K`, +`M`, or `G` suffix uses powers of 1024, and the expanded value must remain in +range. Boolean values are not integers for these fields. + +Combine filters percent-encode whitespace, percent, plus, and Git's reserved +characters. Parsing rejects malformed escapes, decoded NUL bytes, empty child +filters, and nesting beyond 32 levels. `auto` cannot be combined with another +filter. + +`auto` requires Git 2.54 or newer. libvcs passes it only to `git clone` and +`git fetch`; an older Git reports the capability error. `git pull` rejects all +filters, and `git submodule update` rejects `auto` before starting Git. + +```{eval-rst} +.. automodule:: libvcs.cmd.git_filter + :members: + :show-inheritance: +``` diff --git a/docs/cmd/git/index.md b/docs/cmd/git/index.md index 7f979bd93..d9034a73a 100644 --- a/docs/cmd/git/index.md +++ b/docs/cmd/git/index.md @@ -61,6 +61,7 @@ True :caption: Subcommands :maxdepth: 1 +filter submodule remote stash diff --git a/docs/sync/git.md b/docs/sync/git.md index d13893550..d5bd740af 100644 --- a/docs/sync/git.md +++ b/docs/sync/git.md @@ -12,6 +12,85 @@ Compare to: [`salt.states.git`](https://docs.saltproject.io/en/latest/ref/states/all/salt.states.git.html), [`ansible.builtin.git`](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/git_module.html) +## Partial clones + +Pass any {ref}`validated Git filter ` through +{class}`~libvcs.sync.git.GitOptions`. The filter applies to the initial clone +and to submodules created during that obtain. An existing checkout keeps its +configured partial-clone filter during updates. + +```python +>>> from libvcs.cmd.git_filter import BlobNone +>>> from libvcs.sync.git import GitOptions, GitSync +>>> repo = GitSync( +... url="https://example.com/project.git", +... path=tmp_path / "project", +... options=GitOptions(filter=BlobNone()), +... ) +>>> repo.options.filter +('blob:none',) +``` + +{class}`~libvcs.cmd.git_filter.Auto` is available for the initial clone with +Git 2.54 or newer. After cloning, `GitSync` checks the index for submodule +gitlinks. With no gitlinks, the empty submodule update runs without a filter. +If gitlinks exist, obtain raises `ValueError` before initializing them and +leaves the parent clone at the destination. Existing checkout updates do not +change their configured filter. + +## Update and recovery behavior + +Following a branch requires fast-forward ancestry and retains ahead local +commits. Select a tag or commit explicitly when you want a detached checkout. +To detach at a branch's resolved target, pass `detach=True` to `update_repo()`. +The branch resolves after fetching under the ownership lock, including the +normal fallback to a local branch when no tracking ref exists. Existing +keep/warn policies still leave attachment unchanged. +Use the shared {ref}`policy guide ` and executing +{ref}`recovery example ` to handle results and retained tokens. + +Preservation uses indexed stash application to retain staged and unstaged +changes separately. An index conflict remains a conflict; restoration does +not retry without the index. Ordinary ignored output is not dirt, but updates +protect ignored files that collide with the target. + +Offline recovery needs the retained local common object database. Missing +objects in a partial clone cause recovery to fail without fetching. Keep that +database until you release its tokens. Preservation rejects submodule scopes +and independent nested repositories; clean recursive updates remain available. + +## Linked worktrees + +{meth}`~libvcs.sync.git.GitSync.create_worktree` creates an absent destination +at a typed target. It fetches and resolves the target under the common repository +lock, then creates the checkout and initializes its submodules. Parent working +files remain untouched. Branch targets retain ahead local commits, advance only +by fast-forward, and are refused when checked out elsewhere. + +```python +>>> from libvcs.sync.base import SyncTarget +>>> result = example_git_repo.create_worktree( +... tmp_path / "linked", +... target=SyncTarget(commit=example_git_repo.get_revision()), +... lock_reason="retained checkout", +... ) +>>> result.ok +True +>>> result.update_state +'completed' +``` + +`detach=True` detaches at the resolved target. `lock=True` locks the worktree; +`lock_reason` also requests a lock. `set_remotes=True` applies configured remotes +before fetching. Existing interrupted recovery records refuse creation and +retain their token in the result. + +An occupied destination is rejected before branch movement. Native failures +can still leave changed refs or a partial checkout: `update_state="unknown"` +means creation began without reported completion. A completed update with errors +means later lock or submodule setup failed. Creation holds the same ownership +lock as updates; callers must exclude external native writers and editors. + ```{eval-rst} .. automodule:: libvcs.sync.git :members: diff --git a/docs/sync/hg.md b/docs/sync/hg.md index bf15bd42d..6b4d4af9a 100644 --- a/docs/sync/hg.md +++ b/docs/sync/hg.md @@ -6,6 +6,67 @@ creates the checkout, {meth}`~libvcs.sync.hg.HgSync.update_repo` refreshes it, and {meth}`~libvcs.sync.hg.HgSync.get_revision` reads the current revision for [`hg(1)`](https://www.mercurial-scm.org/doc/hg.1.html). +{class}`~libvcs.sync.hg.HgOptions` groups Mercurial transport and clone +settings. The default verifies TLS certificates. + +```python +>>> from libvcs.sync.hg import HgOptions, HgSync +>>> repo = HgSync( +... url="https://example.com/project", +... path=tmp_path / "project", +... options=HgOptions(stream=True), +... ) +>>> repo.options.stream +True +``` + +## Remotes and checkout policy + +Name native fetch and push destinations separately: + +```python +>>> from libvcs import HgRemote, HgSync, SyncPolicy, SyncTarget +>>> repo = HgSync( +... url="https://example.com/project", +... path=tmp_path / "project", +... remotes={"upstream": HgRemote( +... "upstream", "https://example.com/project", "ssh://example.com/publish" +... )}, +... ) +>>> target = SyncTarget(branch="default", remote="upstream") +>>> policy = SyncPolicy(dirty="preserve") +``` + +Pass `target` and `policy` to `update_repo()`. The remote selects the pull +source; its push URL stays separate. The shared {ref}`recovery example ` +executes an update and checks its result against a disposable repository. +A synthesized `default` alias retains +an existing push destination. An explicitly configured alias supplies both +destinations, with push defaulting to its fetch URL when omitted. + +`remotes()` reads effective aliases, including native `%include` files. +`set_remotes(overwrite=True)` writes configured aliases atomically. The method +preserves comments and included files and owns a final `[paths]` block in +`.hg/hgrc`; keep that block last when editing the file manually. Without +`overwrite=True`, existing aliases remain unchanged. + +Existing checkouts apply configured remotes after keep/warn and dirty-abort +preflight. New checkouts start at the configured target before drift policy +applies. A method-level target overrides the constructor's revision. +Preservation retains an owned shelf and supplemental native state. Recovery +creates a separate checkout from retained local history; the configured remote +is never contacted during recovery. + +## Preservation limits + +Named branches, bookmarks, tags, and changesets resolve locally. Native +unshelve conflicts retain both the conflict state and recovery token. Keep +the source history until releasing its tokens: deleting that history prevents +offline recovery. Preservation rejects subrepositories, nested repositories, +shared-store layouts, and unfinished native operations. See the shared +{ref}`token lifecycle ` for discovery, separate recovery, and +explicit release. + ```{eval-rst} .. automodule:: libvcs.sync.hg :members: diff --git a/docs/sync/index.md b/docs/sync/index.md index 71596587e..b7cbf9d45 100644 --- a/docs/sync/index.md +++ b/docs/sync/index.md @@ -2,11 +2,25 @@ # Sync - `libvcs.sync` -Keep a local checkout in sync with its remote: {meth}`~libvcs.sync.git.GitSync.obtain` -and its Mercurial and Subversion counterparts clone the repository when it +Keep a local checkout in sync with its remote: {meth}`~libvcs.sync.git.GitSync.update_repo` +and its Mercurial and Subversion counterparts create the checkout when it doesn't exist yet and update it when it does, through {class}`~libvcs.sync.git.GitSync`, {class}`~libvcs.sync.hg.HgSync`, and {class}`~libvcs.sync.svn.SvnSync` — built on top of {mod}`libvcs.cmd`. +Use `obtain()` only to create the initial checkout. + +`update_repo()` returns a {class}`~libvcs.sync.base.SyncResult`. Check its +`ok` flag before proceeding; failed results retain ordered errors with the +operation that failed. These examples use disposable repositories from +libvcs's {doc}`pytest fixtures `. + +```python +>>> result = example_git_repo.update_repo() +>>> result.ok +True +>>> [(error.step, error.message) for error in result.errors] +[] +``` Compare to: [`fabtools.require.git`](https://fabtools.readthedocs.io/en/0.19.0/api/require/git.html), @@ -20,6 +34,117 @@ versions. ::: +## Read the checkout position + +Use `get_position()` to inspect the checkout before choosing an update policy. +It returns an immutable {class}`~libvcs.sync.base.WorkingCopyPosition` from +local metadata. It does not fetch or contact a server. + +```python +>>> position = example_git_repo.get_position() +>>> position.ref_kind +'branch' +>>> position.revision == example_git_repo.get_revision() +True +>>> position.follows +True +``` + +Git reports an attached branch or a detached commit. Mercurial reports its +active bookmark when present, otherwise its named branch. Subversion reports +the checkout URL and root base revision; `mixed` and `switched` identify +working copies whose children cannot be described by the root alone. + +(sync-policies)= + +## Choose an update policy + +The default `SyncPolicy(drift="follow", dirty="abort")` follows the selected +target and aborts on detected local changes. Pass `target=SyncTarget(...)` +and `policy=SyncPolicy(...)` to `update_repo()` to choose another behavior. +A method target replaces the constructor's `rev` default. + +| Policy | Behavior | +| --- | --- | +| `drift="follow"` | Update to the selected target using native VCS rules. | +| `drift="keep"` | Leave an existing checkout at its current position. | +| `drift="warn"` | Keep its position and report a resolved target mismatch. | +| `dirty="abort"` | Refuse updates while ordinary local changes are present. | +| `dirty="preserve"` | Retain recoverable changes, update, then restore them; conflicts remain visible. | +| `dirty="discard"` | Explicitly discard supported ordinary local changes. | + +The library does not prompt for discard. Preservation never falls back to +discard. Existing Git and Hg keep/warn operations inspect local targets +without fetching or changing attachment; different names at the same revision +do not count as drift. New checkouts establish the configured target first. +SVN keep/warn needs a numeric revision for local comparison: remote `HEAD` +cannot be resolved without contacting the server. + +(sync-recovery)= + +## Preserve and recover local changes + +Preservation returns a token when it captures changes. Keep that token even +when `result.ok` is false: `update_state`, `preservation_state`, `conflicts`, +and `errors` describe separate update and restoration outcomes. + +This example selects an earlier Git commit while retaining an untracked note, +then recovers the original checkout into a separate directory: + +```python +>>> from libvcs import SyncPolicy, SyncTarget +>>> repo = example_git_repo +>>> target = SyncTarget(commit=repo.get_revision()) +>>> _ = repo.run(["commit", "--allow-empty", "-m", "local work"]) +>>> original_revision = repo.get_revision() +>>> _ = (repo.path / "notes.txt").write_text("keep this note\n") +>>> result = repo.update_repo(target=target, policy=SyncPolicy(dirty="preserve")) +>>> result.ok, result.update_state, result.preservation_state +(True, 'completed', 'restored') +>>> result.conflicts, result.errors +((), []) +>>> token = result.recovery +>>> assert token is not None +>>> repo.get_revision() == target.commit +True +>>> (repo.path / "notes.txt").read_text() +'keep this note\n' +>>> token in [record.recovery for record in repo.list_recoveries()] +True +>>> destination = tmp_path / "recovered" +>>> recovered = repo.recover_changes(token, destination=destination) +>>> recovered.ok, recovered.preservation_state +(True, 'restored') +>>> from libvcs import GitSync +>>> copy = GitSync(url=repo.url, path=destination) +>>> copy.get_revision() == original_revision +True +>>> (destination / "notes.txt").read_text() +'keep this note\n' +>>> repo.release_changes(token) +>>> repo.list_recoveries() +() +``` + +Release only after verifying the retained changes. Tokens remain after a +successful restoration, conflict, failed update, or separate recovery; +recovery is repeatable until explicit release. `recovery` is `None` when no +capture was needed. Discovery can return damaged or incomplete records with +errors, so a token alone does not certify usable recovery material. + +`recover_changes()` writes a new checkout; it does not overwrite the source +or resume an interrupted update. Its destination must not exist, must have +an existing parent, and must not overlap the source or recovery storage. +Interrupted records block automatic updates; listing them does not resume +work. Prevent external editors and VCS writers during these operations: the +POSIX ownership lock coordinates libvcs operations only. + +Recovery uses local material. Its dependencies and admitted repository layouts +differ for {doc}`Git `, {doc}`Mercurial `, and {doc}`Subversion `. +Process-death tests cover persisted operation boundaries and an active Git +checkout; they do not establish power-loss durability or arbitrary native +command interruption guarantees. + ## Modules ::::{grid} 1 1 2 2 diff --git a/docs/sync/svn.md b/docs/sync/svn.md index 6868f165b..a77dba3c2 100644 --- a/docs/sync/svn.md +++ b/docs/sync/svn.md @@ -7,6 +7,42 @@ copies through {class}`~libvcs.sync.svn.SvnSync`: {meth}`~libvcs.sync.svn.SvnSync.get_revision` reads the current revision for [`svn(1)`](https://svnbook.red-bean.com/en/1.7/svn.ref.svn.html). +{class}`~libvcs.sync.svn.SvnOptions` groups authentication, certificate, +external, and ambient-depth settings. Passwords do not appear in its +representation. + +```python +>>> from libvcs.sync.svn import SvnOptions, SvnSync +>>> options = SvnOptions(username="reader", password="secret", depth="files") +>>> repo = SvnSync( +... url="https://example.com/project", +... path=tmp_path / "project", +... options=options, +... ) +>>> repo.options.depth +'files' +>>> "secret" in repr(repo.options) +False +``` + +## Targets and recovery + +`SyncTarget(rev=...)` accepts a numeric revision or `HEAD`. The configured +repository URL determines whether synchronization updates or switches the +working copy. Local `resolve_target()` cannot resolve remote `HEAD`; use a +numeric revision for keep/warn comparison without server contact. + +Preservation seals a complete physical working copy, including `.svn`, +schedules, properties, unknown and ignored files, and working-tree symlinks. +Recovery creates a separate checkout offline, even after the original source +is deleted or replaced. See the shared {ref}`policy guide ` +and {ref}`token lifecycle ` for result handling and release. + +Preservation admits POSIX format-31 working-copy roots. It rejects externals, +nested working copies, busy or unsupported database schemas, and +administrative symlinks. Missing paths are removed again only after verified +safe native completion; upstream changes remain visible as conflicts. + ```{eval-rst} .. automodule:: libvcs.sync.svn :members: diff --git a/pyproject.toml b/pyproject.toml index 4287d1592..d6110cd93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -250,6 +250,7 @@ convention = "numpy" "src/libvcs/sync/svn.py" = ["BLE001"] [tool.pytest.ini_options] +markers = ["slow: native recovery matrices with multiple independent repositories"] addopts = [ "--tb=short", "--no-header", diff --git a/src/libvcs/__init__.py b/src/libvcs/__init__.py index 052350cbb..ea6922719 100644 --- a/src/libvcs/__init__.py +++ b/src/libvcs/__init__.py @@ -6,19 +6,37 @@ from .__about__ import __version__ from ._internal.run import CmdLoggingAdapter -from .sync.base import BaseSync, SyncError, SyncResult -from .sync.git import GitSync -from .sync.hg import HgSync -from .sync.svn import SvnSync +from .sync.base import ( + BaseSync, + RecoveryToken, + SyncConflict, + SyncError, + SyncPolicy, + SyncResult, + SyncTarget, + WorkingCopyPosition, +) +from .sync.git import GitOptions, GitSync +from .sync.hg import HgOptions, HgRemote, HgSync +from .sync.svn import SvnOptions, SvnSync __all__ = [ "BaseSync", "CmdLoggingAdapter", + "GitOptions", "GitSync", + "HgOptions", + "HgRemote", "HgSync", + "RecoveryToken", + "SvnOptions", "SvnSync", + "SyncConflict", "SyncError", + "SyncPolicy", "SyncResult", + "SyncTarget", + "WorkingCopyPosition", "__version__", ] diff --git a/src/libvcs/_internal/preservation.py b/src/libvcs/_internal/preservation.py new file mode 100644 index 000000000..0fb5579c1 --- /dev/null +++ b/src/libvcs/_internal/preservation.py @@ -0,0 +1,371 @@ +"""Private recovery records and nonblocking ownership for native VCS adapters.""" + +from __future__ import annotations + +import contextlib +import dataclasses +import hashlib +import json +import os +import pathlib +import re +import shutil +import stat +import tempfile +import typing as t +import uuid +from collections.abc import Iterator + +from libvcs.sync.base import RecoveryToken, SyncConflict, SyncResult + +Record = dict[str, t.Any] +TERMINAL = frozenset({"completed", "conflicted", "failed"}) +PHASES = TERMINAL | {"capturing", "sealed", "updating", "inspecting"} + + +def safe_path(path: pathlib.Path) -> pathlib.Path: + """Reject symlink ancestors rather than silently changing the ownership scope.""" + path = pathlib.Path(os.path.abspath(path)) # noqa: PTH100 - reject before resolving symlinks + for part in (path, *path.parents): + if part.is_symlink(): + msg = f"unsafe symlink in recovery path: {part}" + raise ValueError(msg) + return path + + +def identity(path: pathlib.Path) -> list[int]: + """Bind records to a filesystem object independently of its pathname.""" + info = path.stat() + return [info.st_dev, info.st_ino] + + +def flush_directory(path: pathlib.Path) -> None: + """Flush a directory after publishing an owned record or material.""" + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def atomic_record(path: pathlib.Path, record: Record) -> None: + """Publish complete JSON with file and directory flush ordering.""" + safe_path(path) + fd, temporary = tempfile.mkstemp(prefix=".record-", dir=path.parent) + staging = pathlib.Path(temporary) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(record, stream, sort_keys=True, indent=2, allow_nan=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + staging.replace(path) + flush_directory(path.parent) + finally: + staging.unlink(missing_ok=True) + + +def inventory(root: pathlib.Path) -> dict[str, Record]: + """Inventory files, symlinks, modes, and directories without following links.""" + safe_path(root) + result: dict[str, Record] = {} + for directory, dirs, files in os.walk(root, followlinks=False): + for name in sorted([*dirs, *files]): + path = pathlib.Path(directory) / name + relative = path.relative_to(root).as_posix() + relative.encode("utf-8", errors="strict") + info = path.lstat() + item: Record = {"mode": stat.S_IMODE(info.st_mode)} + if stat.S_ISLNK(info.st_mode): + item.update(kind="symlink", target=str(path.readlink())) + elif stat.S_ISDIR(info.st_mode): + item.update(kind="directory") + elif stat.S_ISREG(info.st_mode): + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + item.update(kind="file", sha256=digest.hexdigest(), size=info.st_size) + else: + msg = f"unsupported recovery file type: {relative}" + raise ValueError(msg) + result[relative] = item + return result + + +class RecoveryStore: + """Keep retained operations beside a checkout, with shared native ownership.""" + + def __init__( + self, + source: pathlib.Path, + backend: str, + repository: pathlib.Path, + *, + lock_in_store: bool = False, + ) -> None: + self.source = safe_path(source) + self.backend = backend + self.repository = safe_path(repository) + key = hashlib.sha256(os.fsencode(self.source)).hexdigest()[:24] + self.root = safe_path(self.source.parent / ".libvcs-recovery" / key) + if self.root.is_relative_to(self.source): + msg = "recovery store overlaps source" + raise ValueError(msg) + self.lock_in_store = lock_in_store + self.lock_path = ( + self.root.parent / (key + ".lock") + if lock_in_store + else self.repository / ".libvcs-preserve.lock" + ) + + @contextlib.contextmanager + def lock(self) -> Iterator[None]: + """Exclude concurrent library operations without waiting.""" + if os.name != "posix": + msg = "recovery requires POSIX ownership locks" + raise NotImplementedError(msg) + import fcntl + + safe_path(self.lock_path) + if self.lock_in_store: + self.lock_path.parent.mkdir(mode=0o700, exist_ok=True) + if self.lock_path.parent.stat().st_mode & 0o077: + msg = "recovery store must be private (mode 0700)" + raise ValueError(msg) + fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + msg = "repository recovery ownership is busy" + raise RuntimeError(msg) from error + yield + finally: + os.close(fd) + + def create( + self, *, original: Record, target: Record + ) -> tuple[RecoveryToken, Record]: + """Publish unique capture intent before the backend saves or cleans anything.""" + for directory in (self.root.parent, self.root): + safe_path(directory) + directory.mkdir(mode=0o700, exist_ok=True) + if directory.stat().st_mode & 0o077: + msg = "recovery store must be private (mode 0700)" + raise ValueError(msg) + operation_id = uuid.uuid4().hex + location = self.root / operation_id + location.mkdir(mode=0o700) + token = RecoveryToken(operation_id, self.backend, str(location)) + record: Record = { + "version": 1, + "id": operation_id, + "backend": self.backend, + "source": str(self.source), + "source_identity": identity(self.source), + "repository": str(self.repository), + "repository_identity": identity(self.repository), + "original": original, + "target": target, + "marker": f"libvcs:{operation_id}", + "phase": "capturing", + "native": {}, + "result": {}, + } + self.write(token, record) + flush_directory(self.root) + return token, record + + def token_path(self, token: RecoveryToken) -> pathlib.Path: + """Validate token scope before reading, recovering, or releasing material.""" + if token.backend != self.backend: + msg = "recovery token backend does not match" + raise ValueError(msg) + if re.fullmatch(r"[0-9a-f]{32}", token.id) is None: + msg = "invalid recovery token id" + raise ValueError(msg) + expected = self.root / token.id + if token.location != str(expected): + msg = "recovery token location does not match this checkout" + raise ValueError(msg) + safe_path(expected) + return expected + + def read(self, token: RecoveryToken, *, require_repository: bool = True) -> Record: + """Validate the envelope and, by default, current native administration.""" + path = self.token_path(token) / "operation.json" + safe_path(path) + value: object = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + msg = "invalid recovery record" + raise ValueError(msg) # noqa: TRY004 - persisted schema validation + record: Record = value + expected = { + "version": 1, + "id": token.id, + "backend": self.backend, + "source": str(self.source), + "repository": str(self.repository), + "marker": f"libvcs:{token.id}", + } + if require_repository: + expected["repository_identity"] = identity(self.repository) + for field in ("source_identity", "repository_identity"): + value = record.get(field) + if ( + not isinstance(value, list) + or len(value) != 2 + or any(type(part) is not int or part < 0 for part in value) + ): + msg = "invalid recovery record filesystem identity" + raise ValueError(msg) + if any(record.get(key) != value for key, value in expected.items()): + msg = "recovery record identity does not match" + raise ValueError(msg) + phase = record.get("phase") + if ( + not isinstance(phase, str) + or phase not in PHASES + or any( + not isinstance(record.get(key), dict) + for key in ("original", "target", "native", "result") + ) + ): + msg = "invalid recovery record phase or payload" + raise ValueError(msg) + self._validate_result(record["result"]) + return record + + @staticmethod + def _validate_result(data: Record) -> None: + for key, allowed in ( + ("update_state", {"not-started", "completed", "failed", "unknown"}), + ( + "preservation_state", + {"not-needed", "saved", "restored", "conflicted", "failed", "unknown"}, + ), + ): + value = data.get(key, "unknown") + if not isinstance(value, str) or value not in allowed: + msg = "invalid persisted recovery result state" + raise ValueError(msg) + for key, fields in ( + ("errors", {"step", "message"}), + ("conflicts", {"path", "reason"}), + ): + values = data.get(key, []) + if not isinstance(values, list) or any( + not isinstance(value, dict) + or set(value) != fields + or any(not isinstance(value[field], str) for field in fields) + for value in values + ): + msg = "invalid persisted recovery result details" + raise ValueError(msg) + + def validate_source(self, record: Record) -> None: + """Refuse ownership inferred from a replaced checkout's old pathname.""" + safe_path(self.source) + if record.get("source_identity") != identity(self.source): + msg = "recovery source identity changed" + raise ValueError(msg) + + def write(self, token: RecoveryToken, record: Record) -> None: + """Replace only the mutable operation envelope.""" + atomic_record(self.token_path(token) / "operation.json", record) + + def phase(self, token: RecoveryToken, record: Record, phase: str) -> None: + """Persist a phase before starting its next native mutation.""" + if not isinstance(phase, str) or phase not in PHASES: + msg = "invalid recovery phase" + raise ValueError(msg) + record["phase"] = phase + self.write(token, record) + + def finish(self, token: RecoveryToken, record: Record, result: SyncResult) -> None: + """Publish known outcomes without serializing live exception instances.""" + record["result"] = { + "update_state": result.update_state, + "preservation_state": result.preservation_state, + "errors": [ + {"step": error.step, "message": error.message} + for error in result.errors + ], + "conflicts": [dataclasses.asdict(item) for item in result.conflicts], + } + phase = ( + "conflicted" if result.conflicts else "completed" if result.ok else "failed" + ) + self.phase(token, record, phase) + + def snapshot(self, token: RecoveryToken, record: Record) -> SyncResult: + """Expose interrupted records as uncertain outcomes, never as successes.""" + result = SyncResult(recovery=token) + data = record["result"] + self._validate_result(data) + update = data.get("update_state", "unknown") + preservation = data.get("preservation_state", "unknown") + result.update_state = update + result.preservation_state = preservation + for error in data.get("errors", []): + result.add_error(error["step"], error["message"]) + result.conflicts = tuple( + SyncConflict(**item) for item in data.get("conflicts", []) + ) + if record["phase"] not in TERMINAL: + result.add_error( + "interrupted", f"recovery operation stopped during {record['phase']}" + ) + elif result.ok and ( + result.conflicts + or update in {"failed", "unknown"} + or preservation in {"failed", "unknown", "conflicted"} + ): + result.add_error( + "recovery", "retained operation did not complete successfully" + ) + return result + + def discover(self, *, require_repository: bool = True) -> tuple[SyncResult, ...]: + """List damaged and unfinished operation directories as visible errors.""" + if not self.root.exists(): + return () + safe_path(self.root) + results = [] + for path in sorted(self.root.iterdir()): + token = RecoveryToken(path.name, self.backend, str(path)) + try: + results.append( + self.snapshot( + token, self.read(token, require_repository=require_repository) + ) + ) + except (OSError, ValueError, KeyError, TypeError) as error: + result = SyncResult( + recovery=token, update_state="unknown", preservation_state="unknown" + ) + result.add_error("recovery-record", str(error), error) + results.append(result) + return tuple(results) + + def destination(self, value: str | os.PathLike[str]) -> pathlib.Path: + """Require a new destination outside source and retained material.""" + destination = safe_path(pathlib.Path(value)) + for scope in (self.source, self.root, self.repository): + if destination.is_relative_to(scope) or scope.is_relative_to(destination): + msg = "recovery destination overlaps source or retained material" + raise ValueError(msg) + if destination.exists(): + msg = "recovery destination already exists" + raise ValueError(msg) + if not destination.parent.is_dir(): + msg = "recovery destination parent must exist" + raise ValueError(msg) + return destination + + def remove(self, token: RecoveryToken, *, require_repository: bool = True) -> None: + """Remove a validated owned envelope after its native material is released.""" + self.read(token, require_repository=require_repository) + shutil.rmtree(self.token_path(token)) + flush_directory(self.root) diff --git a/src/libvcs/_internal/run.py b/src/libvcs/_internal/run.py index c74481cf2..9d3a6ad6e 100644 --- a/src/libvcs/_internal/run.py +++ b/src/libvcs/_internal/run.py @@ -267,8 +267,6 @@ def progress_cb(output, timestamp): ) all_output: str = "" - code = None - line = None if log_in_real_time and callback is None: def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None: @@ -277,34 +275,22 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None: callback = progress_cb - # Note: When git detects that stderr is not a TTY (e.g., when piped), - # it outputs progress with newlines instead of carriage returns. - # This causes each progress update to appear on a new line. - # To get proper single-line progress updates, git would need to be - # connected to a pseudo-TTY, which would require significant changes - # to how subprocess execution is handled. - - timeout_stdout: bytes | None = None - timeout_stderr: bytes | None = None - if timeout is None: - while code is None: - code = proc.poll() - - if callback and callable(callback) and proc.stderr is not None: - line = console_to_str(proc.stderr.read(128)) - if line: - callback( - output=line, - timestamp=datetime.datetime.now(tz=datetime.timezone.utc), - ) - else: + try: code, timeout_stdout, timeout_stderr = _wait_with_deadline( proc, - deadline=time.monotonic() + timeout, + deadline=time.monotonic() + timeout if timeout is not None else None, timeout=timeout, callback=callback, cmd=_stringify_command(normalized_args), ) + except BaseException: + try: + _terminate_process(proc, _stringify_command(normalized_args)) + finally: + for stream in (proc.stdin, proc.stdout, proc.stderr): + if stream is not None: + stream.close() + raise if callback and callable(callback): callback(output="\r", timestamp=datetime.datetime.now(tz=datetime.timezone.utc)) @@ -348,12 +334,12 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None: def _wait_with_deadline( proc: subprocess.Popen[bytes], *, - deadline: float, - timeout: float, + deadline: float | None, + timeout: float | None, callback: ProgressCallbackProtocol | None, cmd: str | list[str], ) -> tuple[int, bytes | None, bytes | None]: - """Wait for ``proc`` to exit, enforcing a wall-clock deadline. + """Drain child pipes while waiting, enforcing an optional wall-clock deadline. Drains both ``stdout`` and ``stderr`` concurrently so a child that fills either kernel pipe buffer (~64 KiB on Linux) cannot deadlock waiting for @@ -429,8 +415,8 @@ def _wait_with_deadline( ) break - remaining = deadline - time.monotonic() - if remaining <= 0: + remaining = deadline - time.monotonic() if deadline is not None else None + if remaining is not None and remaining <= 0: # ``vcs_exit_code`` deliberately omitted here: ``proc.returncode`` # is still ``None`` because the child has not been signalled yet, # and CLAUDE.md treats ``vcs_exit_code`` as a scalar ``int``. @@ -454,12 +440,16 @@ def _wait_with_deadline( timeout=timeout, ) - wait = min(_TIMEOUT_POLL_INTERVAL_SECONDS, remaining) + wait = ( + min(_TIMEOUT_POLL_INTERVAL_SECONDS, remaining) + if remaining is not None + else _TIMEOUT_POLL_INTERVAL_SECONDS + ) if not registered: - # No streams to select on (e.g. ``os.set_blocking`` failed on - # Windows pipes). Yield the CPU explicitly instead of busy- - # looping until the deadline or process exit. - time.sleep(wait) + try: + proc.wait(timeout=remaining) + except subprocess.TimeoutExpired: + continue continue events = sel.select(timeout=wait) diff --git a/src/libvcs/_internal/shortcuts.py b/src/libvcs/_internal/shortcuts.py index f4ae20bd7..3b178aabd 100644 --- a/src/libvcs/_internal/shortcuts.py +++ b/src/libvcs/_internal/shortcuts.py @@ -8,11 +8,22 @@ from __future__ import annotations import typing as t - -from libvcs import GitSync, HgSync, SvnSync, exc +from collections.abc import Mapping + +from libvcs import ( + GitOptions, + GitSync, + HgOptions, + HgSync, + SvnOptions, + SvnSync, + exc, +) from libvcs._internal.run import ProgressCallbackProtocol from libvcs._internal.types import StrPath, VCSLiteral from libvcs.exc import InvalidVCS +from libvcs.sync.git import GitRemotesArgs +from libvcs.sync.hg import HgRemote from libvcs.url import registry as url_tools @@ -38,7 +49,9 @@ def create_project( path: StrPath, vcs: t.Literal["git"], progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: t.Any, + options: GitOptions | None = None, + rev: str | None = None, + remotes: GitRemotesArgs = None, ) -> GitSync: ... @@ -49,7 +62,8 @@ def create_project( path: StrPath, vcs: t.Literal["svn"], progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: t.Any, + options: SvnOptions | None = None, + rev: str | None = None, ) -> SvnSync: ... @@ -60,7 +74,9 @@ def create_project( path: StrPath, vcs: t.Literal["hg"], progress_callback: ProgressCallbackProtocol | None = ..., - **kwargs: t.Any, + options: HgOptions | None = None, + rev: str | None = None, + remotes: Mapping[str, HgRemote | str | Mapping[str, str]] | None = None, ) -> HgSync: ... @@ -71,7 +87,9 @@ def create_project( path: StrPath, vcs: None = None, progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: t.Any, + options: GitOptions | HgOptions | SvnOptions | None = None, + rev: str | None = None, + remotes: GitRemotesArgs | Mapping[str, HgRemote | str | Mapping[str, str]] = None, ) -> GitSync | HgSync | SvnSync: ... @@ -81,7 +99,9 @@ def create_project( path: StrPath, vcs: VCSLiteral | None = None, progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: t.Any, + options: GitOptions | HgOptions | SvnOptions | None = None, + rev: str | None = None, + remotes: GitRemotesArgs | Mapping[str, HgRemote | str | Mapping[str, str]] = None, ) -> GitSync | HgSync | SvnSync: r"""Return an object representation of a VCS repository. @@ -140,16 +160,31 @@ def is_vcs(val: t.Any) -> t.TypeGuard[VCSLiteral]: url=url, path=path, progress_callback=progress_callback, - **kwargs, + options=t.cast(GitOptions | None, options), + rev=rev, + remotes=t.cast(GitRemotesArgs, remotes), ) if vcs == "hg": - return HgSync(url=url, path=path, progress_callback=progress_callback, **kwargs) + return HgSync( + url=url, + path=path, + progress_callback=progress_callback, + options=t.cast(HgOptions | None, options), + rev=rev, + remotes=t.cast( + "Mapping[str, HgRemote | str | Mapping[str, str]] | None", remotes + ), + ) if vcs == "svn": + if remotes is not None: + msg = "remotes is only valid for Git and Mercurial projects" + raise TypeError(msg) return SvnSync( url=url, path=path, progress_callback=progress_callback, - **kwargs, + options=t.cast(SvnOptions | None, options), + rev=rev, ) msg = f"VCS {vcs} is not a valid VCS" raise InvalidVCS(msg) diff --git a/src/libvcs/_internal/subprocess.py b/src/libvcs/_internal/subprocess.py index c40715b38..7c6f78b94 100644 --- a/src/libvcs/_internal/subprocess.py +++ b/src/libvcs/_internal/subprocess.py @@ -430,6 +430,7 @@ def run( universal_newlines: bool = ..., *, capture_output: bool = ..., + timeout: float | None = ..., check: bool = ..., encoding: str | None = ..., errors: str | None = ..., @@ -443,6 +444,7 @@ def run( universal_newlines: bool = ..., *, capture_output: bool = ..., + timeout: float | None = ..., check: bool = ..., encoding: str, errors: str | None = ..., @@ -456,6 +458,7 @@ def run( universal_newlines: bool = ..., *, capture_output: bool = ..., + timeout: float | None = ..., check: bool = ..., encoding: str | None = ..., errors: str, @@ -470,6 +473,7 @@ def run( universal_newlines: t.Literal[True], # where the *real* keyword only args start capture_output: bool = ..., + timeout: float | None = ..., check: bool = ..., encoding: str | None = ..., errors: str | None = ..., @@ -483,6 +487,7 @@ def run( universal_newlines: t.Literal[False] = ..., *, capture_output: bool = ..., + timeout: float | None = ..., check: bool = ..., encoding: None = ..., errors: None = ..., diff --git a/src/libvcs/_internal/svn_preservation.py b/src/libvcs/_internal/svn_preservation.py new file mode 100644 index 000000000..dad525886 --- /dev/null +++ b/src/libvcs/_internal/svn_preservation.py @@ -0,0 +1,460 @@ +"""SVN working-copy inspection, sealed copies, and guarded absence restoration.""" + +from __future__ import annotations + +import contextlib +import os +import pathlib +import shutil +import sqlite3 +import stat +import subprocess +import tempfile +import xml.etree.ElementTree as et +from collections.abc import Iterator + +from libvcs import exc +from libvcs._internal import preservation +from libvcs._internal.subprocess import SubprocessCommand +from libvcs.sync.base import RecoveryToken, SyncConflict + +Record = preservation.Record + + +class WorkingCopy: + """Inspect native SVN bytes and bound full-copy recovery to format 31 on POSIX.""" + + def __init__(self, path: pathlib.Path, *, timeout: float | None = None) -> None: + self.path = preservation.safe_path(path) + self.timeout = timeout + + def read(self, args: list[str]) -> bytes: + """Keep XML stdout and diagnostic stderr separate without lossy decoding.""" + command = SubprocessCommand(["svn", "--non-interactive", *args], cwd=self.path) + output = command.run(capture_output=True, check=False, timeout=self.timeout) + if output.returncode: + raise exc.CommandError( + cmd=args, + returncode=output.returncode, + output=os.fsdecode(output.stderr), + ) + return output.stdout + + def xml(self, args: list[str]) -> et.Element: + return et.fromstring(self.read(args)) + + def native(self) -> dict[str, str]: + """Read local status, history metadata, and binary-safe property XML.""" + result = {} + for name, args in { + "status": [ + "status", + "--xml", + "--verbose", + "--no-ignore", + "--ignore-externals", + ], + "info": ["info", "--xml", "--depth", "infinity"], + "properties": ["proplist", "--xml", "--verbose", "--recursive"], + }.items(): + document = self.xml([*args, "--", "."]) + _validate_xml(name, document) + for entry in document.iter(): + if entry.tag == "wcroot-abspath": + entry.text = "__WC__" + path = entry.get("path") + if path and pathlib.Path(path).is_absolute(): + entry.set( + "path", pathlib.Path(path).relative_to(self.path).as_posix() + ) + result[name] = et.canonicalize(et.tostring(document, encoding="unicode")) + return result + + def path_for(self, name: str) -> pathlib.Path: + path = pathlib.PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or name in ("", "."): + msg = "unsafe SVN working-copy path" + raise ValueError(msg) + name.encode("utf-8", errors="strict") + result = self.path / path + preservation.safe_path(result.parent) + return result + + def check_administration(self) -> None: + """Reject links that would leave native recovery dependent on outside files.""" + administration = preservation.safe_path(self.path / ".svn") + for directory, dirs, files in os.walk(administration, followlinks=False): + if any( + (pathlib.Path(directory) / name).is_symlink() + for name in [*dirs, *files] + ): + msg = "linked SVN administrative storage is unsupported" + raise ValueError(msg) + + @contextlib.contextmanager + def reservation(self) -> Iterator[None]: + """Reserve a validated format-31 database without waiting for native writers.""" + if os.name != "posix": + msg = "SVN preservation requires POSIX" + raise ValueError(msg) + self.check_administration() + database_path = preservation.safe_path(self.path / ".svn" / "wc.db") + if not database_path.is_file(): + msg = "unsupported SVN working-copy database" + raise ValueError(msg) + with contextlib.closing( + sqlite3.connect(database_path.as_uri() + "?mode=rw", uri=True, timeout=0) + ) as database: + if database.execute("PRAGMA user_version").fetchone() != (31,): + msg = "unsupported SVN working-copy schema (requires format 31)" + raise ValueError(msg) + required = { + "WC_LOCK": {"wc_id", "local_dir_relpath", "locked_levels"}, + "WORK_QUEUE": {"id", "work"}, + "NODES": {"wc_id", "local_relpath", "op_depth", "presence", "kind"}, + } + for table, columns in required.items(): + actual = { + row[1] for row in database.execute(f"PRAGMA table_info({table})") + } + if not columns <= actual: + msg = "unsupported SVN working-copy table schema" + raise ValueError(msg) + if database.execute("PRAGMA journal_mode").fetchone() != ("delete",): + msg = "unsupported SVN working-copy journal mode" + raise ValueError(msg) + database.execute("BEGIN IMMEDIATE") + try: + if ( + database.execute("SELECT 1 FROM WC_LOCK LIMIT 1").fetchone() + or database.execute("SELECT 1 FROM WORK_QUEUE LIMIT 1").fetchone() + ): + msg = "native SVN working-copy operation is busy" + raise ValueError(msg) + yield + finally: + database.rollback() + + def precondition(self) -> dict[str, str]: + info = self.xml(["info", "--xml", "--", "."]) + if info.findtext("entry/wc-info/wcroot-abspath") != str(self.path): + msg = "SVN synchronization requires the true working-copy root" + raise ValueError(msg) + with self.reservation(): + native = self.native() + for item in et.fromstring(native["properties"]).iter("property"): + if item.get("name") == "svn:externals" and (item.text or "").strip(): + msg = "SVN externals are outside the preservation scope" + raise ValueError(msg) + for item in et.fromstring(native["status"]).iter("wc-status"): + if ( + item.get("item") == "external" + or item.get("file-external") == "true" + ): + msg = "SVN file externals are outside the preservation scope" + raise ValueError(msg) + if item.get("wc-locked") == "true": + msg = "native SVN working-copy operation is busy" + raise ValueError(msg) + for directory, dirs, files in os.walk(self.path, followlinks=False): + if pathlib.Path(directory) != self.path and any( + name in dirs or name in files for name in (".svn", ".git", ".hg") + ): + msg = "nested repository prevents SVN synchronization" + raise ValueError(msg) + dirs[:] = [name for name in dirs if name not in (".svn", ".git", ".hg")] + tree(self.path) + if conflicts(native): + msg = "SVN working copy already has unresolved conflicts" + raise ValueError(msg) + return native + + +def _validate_xml(name: str, document: et.Element) -> None: + if ( + document.tag != name + or (name == "info" and document.find("entry") is None) + or (name == "status" and document.find("target/entry/wc-status") is None) + ): + msg = "unsupported or incomplete SVN inspection XML" + raise et.ParseError(msg) + + +def validate_record(record: Record) -> None: + """Validate persisted native payload shape before any recovery operation.""" + native = record["native"] + if ( + not isinstance(native.get("tree"), dict) + or not isinstance(native.get("metadata"), dict) + or set(native["metadata"]) != {"status", "info", "properties"} + or any(not isinstance(value, str) for value in native["metadata"].values()) + ): + msg = "invalid or incomplete SVN recovery material record" + raise ValueError(msg) + for name, value in native["metadata"].items(): + _validate_xml(name, et.fromstring(value)) + + +def tree(root: pathlib.Path) -> dict[str, Record]: + """Include root permissions and every physical entry, including administration.""" + result = preservation.inventory(root) + result["."] = {"kind": "directory", "mode": stat.S_IMODE(root.stat().st_mode)} + return result + + +def flush_tree(root: pathlib.Path) -> None: + for directory, _, files in os.walk(root, topdown=False, followlinks=False): + for name in files: + path = pathlib.Path(directory) / name + if not path.is_symlink(): + with path.open("rb") as stream: + os.fsync(stream.fileno()) + preservation.flush_directory(pathlib.Path(directory)) + + +def statuses(native: Record) -> dict[str, Record]: + return { + entry.attrib["path"]: dict(status.attrib) + for entry in et.fromstring(native["status"]).iter("entry") + if (status := entry.find("wc-status")) is not None + } + + +def dirty(native: Record) -> bool: + return any( + item.get("item") not in {"normal", "none", "ignored", "external"} + or item.get("props") not in {"normal", "none"} + or item.get("tree-conflicted") == "true" + for item in statuses(native).values() + ) + + +def conflicts(native: Record) -> tuple[SyncConflict, ...]: + result = [] + for name, item in statuses(native).items(): + for key, value, reason in ( + ("item", "conflicted", "text"), + ("props", "conflicted", "property"), + ("tree-conflicted", "true", "tree"), + ): + if item.get(key) == value: + result.append(SyncConflict(name, reason)) + return tuple(result) + + +def nodes(native: Record) -> dict[str, Record]: + return { + entry.attrib["path"]: { + "kind": entry.get("kind"), + "url": entry.findtext("url"), + "last_changed": commit.get("revision") + if (commit := entry.find("commit")) is not None + else None, + "schedule": entry.findtext("wc-info/schedule"), + } + for entry in et.fromstring(native["info"]).findall("entry") + } + + +def scope(entries: Record, name: str) -> Record: + return { + key: value + for key, value in entries.items() + if key == name or key.startswith(name + "/") + } + + +def property_scopes(native: Record) -> dict[str, str]: + return { + entry.attrib["path"]: et.canonicalize(et.tostring(entry, encoding="unicode")) + for entry in et.fromstring(native["properties"]).findall("target") + } + + +def restore_missing(wc: WorkingCopy, original: Record) -> tuple[SyncConflict, ...]: + """Delete only verified unchanged owned paths during the live operation.""" + missing = [ + name + for name, item in statuses(original).items() + if item.get("item") == "missing" + ] + roots = [ + name + for name in missing + if not any( + name.startswith(parent + "/") for parent in missing if name != parent + ) + ] + result = [] + for name in sorted(roots): + try: + _restore_one(wc, original, name) + except ValueError: # noqa: PERF203 - each missing scope has its own conflict + result.append(SyncConflict(name, "missing-intent-upstream-changed")) + return tuple(result) + + +def _restore_one(wc: WorkingCopy, original: Record, name: str) -> None: + path = wc.path_for(name) + current = wc.native() + before, after = scope(nodes(original), name), scope(nodes(current), name) + if ( + not before + or before != after + or any( + not all(item.values()) or item["schedule"] != "normal" + for item in before.values() + ) + ): + msg = "missing scope changed native identity" + raise ValueError(msg) + if scope(property_scopes(original), name) != scope(property_scopes(current), name): + msg = "missing scope changed properties" + raise ValueError(msg) + if any( + item.get("item") not in {"normal", "missing"} + or item.get("props") not in {"none", "normal"} + or item.get("tree-conflicted") == "true" + for item in scope(statuses(current), name).values() + ): + msg = "missing scope contains changes or obstructions" + raise ValueError(msg) + physical = scope(tree(wc.path), name) + if set(physical) - set(after): + msg = "missing scope contains unexpected physical descendants" + raise ValueError(msg) + identities = {} + for relative, item in physical.items(): + candidate = wc.path_for(relative) + actual = candidate.lstat() + identities[relative] = ( + actual.st_dev, + actual.st_ino, + actual.st_mode, + actual.st_size, + actual.st_mtime_ns, + ) + if (item["kind"] == "directory" and after[relative]["kind"] != "dir") or ( + item["kind"] != "directory" and after[relative]["kind"] != "file" + ): + msg = "missing scope changed physical kind" + raise ValueError(msg) + for relative in sorted( + physical, + key=lambda value: len(pathlib.PurePosixPath(value).parts), + reverse=True, + ): + candidate = wc.path_for(relative) + actual = candidate.lstat() + expected: tuple[int, ...] = identities[relative] + observed: tuple[int, ...] = ( + actual.st_dev, + actual.st_ino, + actual.st_mode, + actual.st_size, + actual.st_mtime_ns, + ) + if physical[relative]["kind"] == "directory": + # Removing verified children changes the parent's mtime and size. + observed, expected = observed[:3], expected[:3] + if observed != expected: + msg = "missing scope changed before deletion" + raise ValueError(msg) + if physical[relative]["kind"] == "directory": + candidate.rmdir() + else: + candidate.unlink() + if not physical and (path.exists() or path.is_symlink()): + msg = "missing scope physical inventory is ambiguous" + raise ValueError(msg) + preservation.flush_directory(path.parent) + + +def capture( + wc: WorkingCopy, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: Record, +) -> None: + """Publish an independently verified full copy before native update starts.""" + directory = store.token_path(token) + staging = pathlib.Path(tempfile.mkdtemp(prefix=".material-", dir=directory)) + with wc.reservation(): + native = wc.native() + original = tree(wc.path) + shutil.copytree(wc.path, staging / "wc", symlinks=True) + copied = WorkingCopy(staging / "wc", timeout=wc.timeout) + if ( + tree(wc.path) != original + or tree(copied.path) != original + or copied.native() != native + or wc.native() != native + ): + msg = "SVN working copy changed during capture" + raise ValueError(msg) + # Diff may require repository history for copies from outside this checkout. + try: + patch = wc.read(["diff", "--", "."]) + (staging / "changes.diff").write_bytes(patch) + except (exc.CommandError, subprocess.SubprocessError) as error: + record["diff_error"] = str(error) + record["native"] = {"tree": original, "metadata": native} + store.write(token, record) + flush_tree(staging) + if tree(wc.path) != original or tree(copied.path) != original: + msg = "SVN working copy changed before publication" + raise ValueError(msg) + staging.replace(directory / "material") + preservation.flush_directory(directory) + store.phase(token, record, "sealed") + + +def material( + wc: WorkingCopy, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: Record, +) -> pathlib.Path: + validate_record(record) + native = record["native"] + root = preservation.safe_path(store.token_path(token) / "material" / "wc") + if tree(root) != native["tree"]: + msg = "sealed SVN working-copy checksum mismatch" + raise ValueError(msg) + retained = WorkingCopy(root, timeout=wc.timeout) + retained.check_administration() + if retained.native() != native["metadata"] or tree(root) != native["tree"]: + msg = "sealed SVN native metadata mismatch" + raise ValueError(msg) + return root + + +def recover( + wc: WorkingCopy, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: Record, + destination: pathlib.Path, +) -> None: + source = material(wc, store, token, record) + staging = pathlib.Path( + tempfile.mkdtemp(prefix=f".libvcs-recover-{token.id}-", dir=destination.parent) + ) + try: + shutil.copytree(source, staging / "wc", symlinks=True) + copied = WorkingCopy(staging / "wc", timeout=wc.timeout) + if ( + tree(copied.path) != record["native"]["tree"] + or copied.native() != record["native"]["metadata"] + or tree(copied.path) != record["native"]["tree"] + ): + msg = "recovered SVN working copy does not match sealed state" + raise ValueError(msg) # noqa: TRY301 - retain staging on verification failure + flush_tree(staging) + store.destination(destination) + (staging / "wc").rename(destination) + preservation.flush_directory(destination.parent) + staging.rmdir() + except Exception as error: + msg = f"SVN recovery staging retained at {staging}: {error}" + raise RuntimeError(msg) from error diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index bdf225a9f..7d931eabc 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -19,10 +19,41 @@ run, ) from libvcs._internal.types import StrOrBytesPath, StrPath +from libvcs.cmd.git_filter import ( + Auto, + Combine, + GitFilter, + GitFilterInput, + coerce_filter, + filter_specs, +) _CMD = StrOrBytesPath | Sequence[StrOrBytesPath] +def _filter_flags( + value: GitFilterInput | None, + *, + allow_auto: bool, + combine_multiple: bool = False, +) -> list[str]: + specs = filter_specs(value) + if not allow_auto and any(spec == Auto().to_spec() for spec in specs): + msg = "auto filter is not supported by this Git command" + raise ValueError(msg) + if combine_multiple and len(specs) > 1: + pending = [coerce_filter(spec) for spec in reversed(specs)] + filters: list[GitFilter] = [] + while pending: + child = pending.pop() + if isinstance(child, Combine): + pending.extend(reversed(child.filters)) + else: + filters.append(child) + specs = (Combine(tuple(filters)).to_spec(),) + return [f"--filter={spec}" for spec in specs] + + class Git: """Run commands directly on a git repository.""" @@ -207,7 +238,7 @@ def run( no_pager : bool ``-P / --no-pager`` config : - ``--config==`` + ``-c =`` for this command and its child processes. config_env : ``--config-env==`` timeout : float, optional @@ -224,7 +255,7 @@ def run( >>> git.run(['help']) "usage: git [...--version] [...--help] [-C ]..." """ - cli_args: list[StrOrBytesPath] = ["git", *_normalize_command_args(args)] + cli_args: list[StrOrBytesPath] = ["git"] if "cwd" not in kwargs: kwargs["cwd"] = self.path if cwd is None else cwd @@ -262,7 +293,7 @@ def stringify(v: t.Any) -> str: return v for k, v in config.items(): - cli_args.extend(["--config", f"{k}={stringify(v)}"]) + cli_args.extend(["-c", f"{k}={stringify(v)}"]) if config_env is not None: cli_args.append(f"--config-env={config_env}") if git_dir is not None: @@ -290,6 +321,8 @@ def stringify(v: t.Any) -> str: if no_optional_locks is True: cli_args.append("--no-optional-locks") + cli_args.extend(_normalize_command_args(args)) + if self.progress_callback is not None: kwargs["callback"] = self.progress_callback @@ -328,6 +361,7 @@ def clone( no_remote_submodules: bool | None = None, verbose: bool | None = None, quiet: bool | None = None, + _filter: GitFilterInput | None = None, # Pass-through to run config: dict[str, t.Any] | None = None, log_in_real_time: bool = False, @@ -367,8 +401,7 @@ def clone( local_flags.append(f"--template={template}") if separate_git_dir is not None: local_flags.append(f"--separate-git-dir={separate_git_dir!s}") - if (_filter := kwargs.pop("_filter", None)) is not None: - local_flags.append(f"--filter={_filter}") + local_flags.extend(_filter_flags(_filter, allow_auto=True)) if depth is not None: local_flags.extend(["--depth", str(depth)]) if branch is not None: @@ -477,7 +510,9 @@ def fetch( show_forced_updates: bool | None = None, no_show_forced_updates: bool | None = None, negotiate_only: bool | None = None, + _filter: GitFilterInput | None = None, # libvcs special behavior + config: dict[str, t.Any] | None = None, check_returncode: bool | None = None, **kwargs: t.Any, ) -> str: @@ -503,8 +538,7 @@ def fetch( if submodule_prefix is not None: local_flags.append(f"--submodule-prefix={submodule_prefix!r}") - if (_filter := kwargs.pop("_filter", None)) is not None: - local_flags.append(f"--filter={_filter}") + local_flags.extend(_filter_flags(_filter, allow_auto=True)) if depth is not None: local_flags.extend(["--depth", depth]) if deepen is not None: @@ -595,6 +629,7 @@ def fetch( local_flags.append("--negotiate-only") return self.run( ["fetch", *local_flags, "--", *required_flags], + config=config, check_returncode=check_returncode, ) @@ -891,6 +926,7 @@ def pull( show_forced_updates: bool | None = None, no_show_forced_updates: bool | None = None, negotiate_only: bool | None = None, + _filter: GitFilterInput | None = None, # Pass-through to run log_in_real_time: bool = False, check_returncode: bool | None = None, @@ -999,8 +1035,9 @@ def pull( # if submodule_prefix is not None: local_flags.append(f"--submodule-prefix={submodule_prefix!r}") - if (_filter := kwargs.pop("_filter", None)) is not None: - local_flags.append(f"--filter={_filter}") + if filter_specs(_filter): + msg = "git pull does not accept filters; call Git.fetch with _filter first" + raise ValueError(msg) if depth is not None: local_flags.extend(["--depth", depth]) if deepen is not None: @@ -2532,6 +2569,8 @@ def update( rebase: bool | None = None, merge: bool | None = None, recursive: bool | None = None, + depth: int | None = None, + _filter: GitFilterInput | None = None, # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, @@ -2571,20 +2610,27 @@ def update( if force is True: local_flags.append("--force") + if recursive is True: + local_flags.append("--recursive") + if depth is not None: + local_flags.extend(["--depth", str(depth)]) + if checkout is True: local_flags.append("--checkout") elif rebase is True: local_flags.append("--rebase") elif merge is True: local_flags.append("--merge") - if (_filter := kwargs.pop("_filter", None)) is not None: - local_flags.append(f"--filter={_filter}") + local_flags.extend( + _filter_flags(_filter, allow_auto=False, combine_multiple=True) + ) return self.run( "update", local_flags=[*local_flags, "--", *required_flags], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + **kwargs, ) @@ -4423,6 +4469,7 @@ def ls(self) -> QueryList[GitRemoteCmd]: (?P.+?) # URL: any characters (non-greedy) - supports spaces \s+ # One or more whitespace characters \((?Pfetch|push)\) # 'fetch' or 'push' in parentheses + (?:[ \t]+\[[^\]\r\n]*\])? # Partial-clone filter annotation $ # End of line """, re.VERBOSE | re.MULTILINE, @@ -6869,10 +6916,16 @@ def add( Examples -------- - >>> GitWorktreeManager(path=example_git_repo.path).add( - ... path='/tmp/test-worktree-add', commit_ish='HEAD' + >>> worktree_path = tmp_path / "linked" + >>> _ = GitWorktreeManager(path=example_git_repo.path).add( + ... path=worktree_path, commit_ish="HEAD", detach=True, + ... check_returncode=True, ... ) - "Preparing worktree (detached HEAD ...)..." + >>> (worktree_path / ".git").is_file() + True + >>> revision = Git(path=worktree_path).rev_parse(args="HEAD").strip() + >>> revision == example_git_repo.get_revision() + True """ local_flags: list[str] = [] @@ -7300,14 +7353,14 @@ def edit( Examples -------- - Use config to override editor (avoids interactive editor): + Set ``GIT_EDITOR`` for a noninteractive command: >>> result = GitNoteCmd( ... path=example_git_repo.path, ... object_sha='HEAD', - ... ).edit(allow_empty=True, config={'core.editor': 'true'}) - >>> 'error' in result.lower() or result == '' - True + ... ).edit(allow_empty=True, env=dict(os.environ, GIT_EDITOR='true')) + >>> result + '' """ local_flags: list[str] = [] diff --git a/src/libvcs/cmd/git_filter.py b/src/libvcs/cmd/git_filter.py new file mode 100644 index 000000000..1fcb69c51 --- /dev/null +++ b/src/libvcs/cmd/git_filter.py @@ -0,0 +1,453 @@ +"""Typed Git object filters for partial clone and fetch operations.""" + +# Filter config exposes one predictable ValueError surface for type and value errors. +# ruff: noqa: TRY004 + +from __future__ import annotations + +import dataclasses +import re +import typing as t +from collections.abc import Mapping, Sequence + +_MAX_UINT = 2**64 - 1 +_MAX_NESTING = 32 +_GIT_ULONG_RE = re.compile( + r"[\t\v\f ]*\+?" + r"(?P0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)" + r"(?P[kKmMgG]?)\Z" +) +_LIMIT_MULTIPLIERS = {"": 1, "k": 1024, "m": 1024**2, "g": 1024**3} +_OBJECT_TYPES = ("blob", "tree", "commit", "tag") +_RESERVED_NON_WHITESPACE = frozenset("~`!@#$^&*()[]{}\\;'\",<>?") + + +def _validate_uint(value: object, *, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + msg = f"{field} must be an integer" + raise ValueError(msg) + if not 0 <= value <= _MAX_UINT: + msg = f"{field} must be between 0 and {_MAX_UINT}" + raise ValueError(msg) + return value + + +def _parse_git_ulong(value: str, *, field: str) -> int: + match = _GIT_ULONG_RE.fullmatch(value) + if match is None: + msg = f"{field} must use Git unsigned-long syntax" + raise ValueError(msg) + raw_number = match.group("number") + if raw_number.lower().startswith("0x"): + base = 16 + elif len(raw_number) > 1 and raw_number.startswith("0"): + base = 8 + else: + base = 10 + number = int(raw_number, base) + multiplier = _LIMIT_MULTIPLIERS[match.group("unit").lower()] + if number > _MAX_UINT // multiplier: + msg = f"{field} must not exceed {_MAX_UINT}" + raise ValueError(msg) + return number * multiplier + + +def _validate_limit(value: object) -> None: + if isinstance(value, bool) or not isinstance(value, (int, str)): + msg = "limit must be an integer or Git unsigned-long string" + raise ValueError(msg) + if isinstance(value, int): + _validate_uint(value, field="limit") + return + _parse_git_ulong(value, field="limit") + + +def _validate_oid(value: object) -> None: + if not isinstance(value, str) or not value: + msg = "oid must be a nonempty string" + raise ValueError(msg) + if any(character in value for character in ("\x00", "\r", "\n")): + msg = "oid must not contain NUL or newlines" + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class BlobNone: + """Omit all blobs until Git needs them.""" + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return "blob:none" + + +@dataclasses.dataclass(frozen=True) +class BlobLimit: + """Omit blobs at or above a byte limit.""" + + limit: int | str + """Byte count as an integer or Git unsigned-long string.""" + + def __post_init__(self) -> None: + """Validate the limit against Git's unsigned-long grammar.""" + _validate_limit(self.limit) + if isinstance(self.limit, str) and re.fullmatch( + r"(?:0|[1-9][0-9]*)", self.limit + ): + object.__setattr__(self, "limit", int(self.limit)) + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return f"blob:limit={self.limit}" + + +@dataclasses.dataclass(frozen=True) +class TreeDepth: + """Omit trees and blobs at or beyond a traversal depth.""" + + depth: int + """Maximum unsigned 64-bit traversal depth.""" + + def __post_init__(self) -> None: + """Validate the traversal depth.""" + _validate_uint(self.depth, field="depth") + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return f"tree:{self.depth}" + + +@dataclasses.dataclass(frozen=True) +class ObjectType: + """Include one Git object type.""" + + type: t.Literal["blob", "tree", "commit", "tag"] + """Git object type to include.""" + + def __post_init__(self) -> None: + """Validate the object type.""" + if self.type not in _OBJECT_TYPES: + msg = "type must be blob, tree, commit, or tag" + raise ValueError(msg) + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return f"object:type={self.type}" + + +@dataclasses.dataclass(frozen=True) +class SparseOid: + """Read sparse-checkout patterns from a Git object.""" + + oid: str + """Object name containing sparse-checkout patterns.""" + + def __post_init__(self) -> None: + """Validate the object name.""" + _validate_oid(self.oid) + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return f"sparse:oid={self.oid}" + + +@dataclasses.dataclass(frozen=True) +class Auto: + """Let Git choose the filter from server recommendations.""" + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return "auto" + + +@dataclasses.dataclass(frozen=True) +class Combine: + """Apply each child filter as one Git combine filter.""" + + filters: tuple[GitFilter, ...] + """Nonempty tuple of child filters.""" + + def __post_init__(self) -> None: + """Validate the child filters.""" + if not isinstance(self.filters, tuple) or not self.filters: + msg = "filters must be a nonempty tuple" + raise ValueError(msg) + for index, child in enumerate(self.filters): + if not isinstance(child, _FILTER_TYPES): + msg = f"filters[{index}] must be a Git filter" + raise ValueError(msg) + if isinstance(child, Auto): + msg = f"filters[{index}]: auto cannot be combined" + raise ValueError(msg) + stack = [(child, 1) for child in self.filters] + while stack: + child, depth = stack.pop() + if depth > _MAX_NESTING: + msg = f"filter nesting exceeds {_MAX_NESTING} levels" + raise ValueError(msg) + if isinstance(child, Combine): + stack.extend((nested, depth + 1) for nested in child.filters) + + def to_spec(self) -> str: + """Return Git's canonical filter specification.""" + return "combine:" + "+".join( + _encode_subfilter(child.to_spec()) for child in self.filters + ) + + +GitFilter: t.TypeAlias = ( + BlobNone | BlobLimit | TreeDepth | ObjectType | SparseOid | Auto | Combine +) +"""A validated Git partial-clone filter.""" + +GitFilterInput: t.TypeAlias = GitFilter | str | Mapping[str, object] | Sequence[object] +"""A filter model, spec, kind-tagged mapping, or nonempty sequence.""" + +_FILTER_TYPES = (BlobNone, BlobLimit, TreeDepth, ObjectType, SparseOid, Auto, Combine) + + +def _encode_subfilter(spec: str) -> str: + encoded: list[str] = [] + for byte in spec.encode(): + character = chr(byte) + if ( + 32 < byte < 127 + and character not in _RESERVED_NON_WHITESPACE + and character not in "%+" + ): + encoded.append(character) + else: + encoded.append(f"%{byte:02X}") + return "".join(encoded) + + +def _decode_subfilter(spec: str) -> str: + decoded = bytearray() + index = 0 + while index < len(spec): + character = spec[index] + if character == "%": + escape = spec[index + 1 : index + 3] + if len(escape) != 2 or not all( + c in "0123456789abcdefABCDEF" for c in escape + ): + msg = "malformed percent escape in combine filter" + raise ValueError(msg) + decoded.append(int(escape, 16)) + index += 3 + continue + if ord(character) <= 32 or character in _RESERVED_NON_WHITESPACE: + msg = f"reserved character {character!r} in combine filter" + raise ValueError(msg) + decoded.extend(character.encode()) + index += 1 + if 0 in decoded: + msg = "NUL is not allowed in a filter" + raise ValueError(msg) + try: + return decoded.decode() + except UnicodeDecodeError as error: + msg = "combine filter is not valid UTF-8" + raise ValueError(msg) from error + + +def _parse_filter(spec: str, *, depth: int) -> GitFilter: + if depth > _MAX_NESTING: + msg = f"filter nesting exceeds {_MAX_NESTING} levels" + raise ValueError(msg) + if not isinstance(spec, str) or not spec: + msg = "filter spec must be a nonempty string" + raise ValueError(msg) + if any(character in spec for character in ("\x00", "\r", "\n")): + msg = "filter spec must not contain NUL or newlines" + raise ValueError(msg) + if spec == "blob:none": + return BlobNone() + if spec.startswith("blob:limit="): + return BlobLimit(spec.removeprefix("blob:limit=")) + if spec.startswith("tree:"): + raw_depth = spec.removeprefix("tree:") + return TreeDepth(_parse_git_ulong(raw_depth, field="depth")) + if spec.startswith("object:type="): + raw_type = spec.removeprefix("object:type=") + if raw_type not in _OBJECT_TYPES: + msg = "type must be blob, tree, commit, or tag" + raise ValueError(msg) + return ObjectType(t.cast(t.Literal["blob", "tree", "commit", "tag"], raw_type)) + if spec.startswith("sparse:oid="): + return SparseOid(spec.removeprefix("sparse:oid=")) + if spec == "auto": + return Auto() + if spec.startswith("combine:"): + raw_children = spec.removeprefix("combine:").split("+") + if not raw_children or any(not child for child in raw_children): + msg = "combine filters must contain nonempty children" + raise ValueError(msg) + children: list[GitFilter] = [] + for index, raw_child in enumerate(raw_children): + try: + child = _parse_filter(_decode_subfilter(raw_child), depth=depth + 1) + except ValueError as error: + msg = f"filters[{index}]: {error}" + raise ValueError(msg) from None + if isinstance(child, Auto): + msg = f"filters[{index}]: auto cannot be combined" + raise ValueError(msg) + children.append(child) + return Combine(tuple(children)) + msg = f"invalid filter spec {spec!r}" + raise ValueError(msg) + + +def parse_filter(spec: str) -> GitFilter: + """Parse one Git filter specification without running Git.""" + return _parse_filter(spec, depth=0) + + +def _check_mapping_fields( + value: Mapping[str, object], + *, + expected: frozenset[str], +) -> None: + fields = set(value) + unexpected = fields - expected + missing = expected - fields + if unexpected: + msg = f"unsupported field {min(unexpected, key=str)!r}" + raise ValueError(msg) + if missing: + msg = f"missing field {min(missing)!r}" + raise ValueError(msg) + + +def _from_mapping(value: Mapping[str, object], *, depth: int) -> GitFilter: + if depth > _MAX_NESTING: + msg = f"filter nesting exceeds {_MAX_NESTING} levels" + raise ValueError(msg) + kind = value.get("kind") + if not isinstance(kind, str): + msg = "kind must be a string" + raise ValueError(msg) + if kind == "blob:none": + _check_mapping_fields(value, expected=frozenset(("kind",))) + return BlobNone() + if kind == "blob:limit": + _check_mapping_fields(value, expected=frozenset(("kind", "limit"))) + return BlobLimit(t.cast(int | str, value["limit"])) + if kind == "tree": + _check_mapping_fields(value, expected=frozenset(("kind", "depth"))) + return TreeDepth(t.cast(int, value["depth"])) + if kind == "object:type": + _check_mapping_fields(value, expected=frozenset(("kind", "type"))) + return ObjectType( + t.cast(t.Literal["blob", "tree", "commit", "tag"], value["type"]) + ) + if kind == "sparse:oid": + _check_mapping_fields(value, expected=frozenset(("kind", "oid"))) + return SparseOid(t.cast(str, value["oid"])) + if kind == "auto": + _check_mapping_fields(value, expected=frozenset(("kind",))) + return Auto() + if kind == "combine": + _check_mapping_fields(value, expected=frozenset(("kind", "filters"))) + raw_filters = value["filters"] + if ( + not isinstance(raw_filters, Sequence) + or isinstance(raw_filters, (str, bytes, bytearray)) + or not raw_filters + ): + msg = "filters must be a nonempty sequence" + raise ValueError(msg) + filters: list[GitFilter] = [] + for index, raw_filter in enumerate(raw_filters): + try: + child = _coerce_filter(raw_filter, depth=depth + 1) + except ValueError as error: + msg = f"filters[{index}]: {error}" + raise ValueError(msg) from None + if isinstance(child, Auto): + msg = f"filters[{index}]: auto cannot be combined" + raise ValueError(msg) + filters.append(child) + return Combine(tuple(filters)) + msg = f"unknown kind {kind!r}" + raise ValueError(msg) + + +def from_mapping(value: Mapping[str, object]) -> GitFilter: + """Build one Git filter from a kind-tagged mapping.""" + return _from_mapping(value, depth=0) + + +def _coerce_filter(value: object, *, depth: int) -> GitFilter: + if depth > _MAX_NESTING: + msg = f"filter nesting exceeds {_MAX_NESTING} levels" + raise ValueError(msg) + if isinstance(value, _FILTER_TYPES): + return value + if isinstance(value, str): + return _parse_filter(value, depth=depth) + if isinstance(value, Mapping): + return _from_mapping(value, depth=depth) + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + if not value: + msg = "filter sequence must be nonempty" + raise ValueError(msg) + filters: list[GitFilter] = [] + for index, item in enumerate(value): + try: + child = _coerce_filter(item, depth=depth + 1) + except ValueError as error: + msg = f"filter[{index}]: {error}" + raise ValueError(msg) from None + if isinstance(child, Auto): + msg = f"filter[{index}]: auto cannot be combined" + raise ValueError(msg) + filters.append(child) + return Combine(tuple(filters)) + msg = "filter must be a model, spec string, mapping, or nonempty sequence" + raise ValueError(msg) + + +def coerce_filter(value: object) -> GitFilter: + """Validate and convert one accepted filter value to a model.""" + return _coerce_filter(value, depth=0) + + +def filter_specs(value: GitFilterInput | None) -> tuple[str, ...]: + """Return canonical specs, preserving one flag per sequence item.""" + if value is None: + return () + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if not value: + msg = "filter sequence must be nonempty" + raise ValueError(msg) + filters: list[GitFilter] = [] + for index, item in enumerate(value): + try: + child = _coerce_filter(item, depth=0) + except ValueError as error: + msg = f"filter[{index}]: {error}" + raise ValueError(msg) from None + if isinstance(child, Auto): + msg = f"filter[{index}]: auto cannot be combined" + raise ValueError(msg) + filters.append(child) + return tuple(item.to_spec() for item in filters) + return (coerce_filter(value).to_spec(),) + + +__all__ = [ + "Auto", + "BlobLimit", + "BlobNone", + "Combine", + "GitFilter", + "GitFilterInput", + "ObjectType", + "SparseOid", + "TreeDepth", + "coerce_filter", + "filter_specs", + "from_mapping", + "parse_filter", +] diff --git a/src/libvcs/cmd/hg.py b/src/libvcs/cmd/hg.py index f4fb7e81f..16af8dee6 100644 --- a/src/libvcs/cmd/hg.py +++ b/src/libvcs/cmd/hg.py @@ -164,7 +164,7 @@ def run( >>> hg.run(['help']) "Mercurial Distributed SCM..." """ - cli_args: list[StrOrBytesPath] = ["hg", *_normalize_command_args(args)] + cli_args: list[StrOrBytesPath] = ["hg"] if "cwd" not in kwargs: kwargs["cwd"] = self.path @@ -196,6 +196,8 @@ def run( if _help is True: cli_args.append("--help") + cli_args.extend(_normalize_command_args(args)) + if self.progress_callback is not None: kwargs["callback"] = self.progress_callback @@ -312,9 +314,13 @@ def pull( quiet: bool | None = None, verbose: bool | None = None, update: bool | None = None, + ssh: str | None = None, + remote_cmd: str | None = None, + insecure: bool | None = None, # libvcs special behavior check_returncode: bool | None = True, *args: object, + source: str | None = None, **kwargs: t.Any, ) -> str: r"""Pull changes from a remote repository. @@ -340,7 +346,15 @@ def pull( local_flags.append("--verbose") if update: local_flags.append("--update") + if ssh is not None: + local_flags.extend(["--ssh", ssh]) + if remote_cmd is not None: + local_flags.extend(["--remotecmd", remote_cmd]) + if insecure is True: + local_flags.append("--insecure") return self.run( - ["pull", *local_flags], check_returncode=check_returncode, **kwargs + ["pull", *local_flags, *(["--", source] if source is not None else [])], + check_returncode=check_returncode, + **kwargs, ) diff --git a/src/libvcs/cmd/svn.py b/src/libvcs/cmd/svn.py index 13d11416d..dc6a17230 100644 --- a/src/libvcs/cmd/svn.py +++ b/src/libvcs/cmd/svn.py @@ -139,11 +139,13 @@ def run( >>> svn.run(['help']) "usage: svn [options] [args]..." """ - cli_args: list[StrOrBytesPath] = ["svn", *_normalize_command_args(args)] + cli_args: list[StrOrBytesPath] = ["svn"] if "cwd" not in kwargs: kwargs["cwd"] = self.path + if quiet is True: + cli_args.append("--quiet") if no_auth_cache is True: cli_args.append("--no-auth-cache") if non_interactive is True: @@ -153,12 +155,14 @@ def run( if password is not None: cli_args.extend(["--password", password]) if trust_server_cert is True: - cli_args.append("--trust-server_cert") + cli_args.append("--trust-server-cert") if config_dir is not None: cli_args.extend(["--config-dir", os.fspath(config_dir)]) if config_option is not None: cli_args.extend(["--config-option", os.fspath(config_option)]) + cli_args.extend(_normalize_command_args(args)) + if self.progress_callback is not None: kwargs["callback"] = self.progress_callback diff --git a/src/libvcs/sync/base.py b/src/libvcs/sync/base.py index eea115170..b8c4ede6a 100644 --- a/src/libvcs/sync/base.py +++ b/src/libvcs/sync/base.py @@ -15,6 +15,100 @@ logger = logging.getLogger(__name__) +@dataclasses.dataclass(frozen=True) +class WorkingCopyPosition: + """Describe a checkout using the backend's local metadata. + + Attributes + ---------- + revision : str + Full commit identifier or Subversion base revision. + ref_name : str + Branch, bookmark, detached commit, or checkout URL. + ref_kind : str + Meaning of ``ref_name`` in the backend. + follows : bool + Whether the selected ref follows new revisions on update. + mixed : bool + Whether Subversion entries have different base revisions. + switched : bool + Whether a Subversion subtree follows a different repository URL. + """ + + revision: str + ref_name: str + ref_kind: t.Literal["branch", "bookmark", "tag", "commit", "url"] + follows: bool + mixed: bool = False + switched: bool = False + + +@dataclasses.dataclass(frozen=True) +class SyncTarget: + """Select one backend ref; omission from a sync call follows the current ref.""" + + branch: str | None = None + tag: str | None = None + commit: str | None = None + rev: str | int | None = None + remote: str | None = None + + def __post_init__(self) -> None: + """Reject ambiguous targets and option-like native arguments.""" + selectors = (self.branch, self.tag, self.commit, self.rev) + if sum(value is not None for value in selectors) != 1: + msg = "target requires exactly one of branch, tag, commit, or rev" + raise ValueError(msg) + for name in ("branch", "tag", "commit", "rev", "remote"): + value = getattr(self, name) + if value is None: + continue + if name == "rev" and type(value) is int and value >= 0: + continue + if not isinstance(value, str): + msg = f"{name} must be a string" + ( + " or nonnegative integer" if name == "rev" else "" + ) + raise TypeError(msg) + if not value or value.startswith("-") or "\0" in value: + msg = f"{name} must be nonempty without a leading '-' or NUL" + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class SyncPolicy: + """Control configured-target drift and local changes independently.""" + + drift: t.Literal["keep", "follow", "warn"] = "follow" + dirty: t.Literal["abort", "preserve", "discard"] = "abort" + + def __post_init__(self) -> None: + """Reject unsupported policies before repository access.""" + if self.drift not in ("keep", "follow", "warn"): + msg = "drift must be keep, follow, or warn" + raise ValueError(msg) + if self.dirty not in ("abort", "preserve", "discard"): + msg = "dirty must be abort, preserve, or discard" + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class RecoveryToken: + """Identify retained private recovery material until explicit release.""" + + id: str + backend: str + location: str + + +@dataclasses.dataclass(frozen=True) +class SyncConflict: + """Identify a conflicting relative path and its stable backend reason.""" + + path: str + reason: str + + @dataclasses.dataclass class SyncError: """An error encountered during a sync step. @@ -79,6 +173,15 @@ class SyncResult: ok: bool = True errors: list[SyncError] = dataclasses.field(default_factory=list) + recovery: RecoveryToken | None = None + update_state: t.Literal["not-started", "completed", "failed", "unknown"] = ( + "not-started" + ) + preservation_state: t.Literal[ + "not-needed", "saved", "restored", "conflicted", "failed", "unknown" + ] = "not-needed" + conflicts: tuple[SyncConflict, ...] = () + def __bool__(self) -> bool: """Return True if the sync succeeded without errors. @@ -160,7 +263,7 @@ def __init__( url: str, path: StrPath, progress_callback: ProgressCallbackProtocol | None = None, - **kwargs: t.Any, + rev: str | None = None, ) -> None: r"""Initialize a tool to manage a local VCS Checkout, Clone, Copy, or Work tree. @@ -212,8 +315,7 @@ def __init__( else: self.path = pathlib.Path(path) - if "rev" in kwargs: - self.rev = kwargs["rev"] + self.rev = rev # Register more schemes with urlparse for various version control # systems @@ -288,6 +390,32 @@ def run( cwd=cwd, ) + def get_position(self) -> WorkingCopyPosition: + """Read the backend's local checkout metadata without contacting a remote.""" + raise NotImplementedError + + def resolve_target(self, target: SyncTarget | None = None) -> WorkingCopyPosition: + """Resolve a target from available metadata without fetching or checking out.""" + raise NotImplementedError + + def is_dirty(self) -> bool: + """Read native local-change metadata without contacting a remote.""" + raise NotImplementedError + + def list_recoveries(self) -> tuple[SyncResult, ...]: + """List retained and interrupted operations without resuming their updates.""" + raise NotImplementedError + + def recover_changes( + self, token: RecoveryToken, *, destination: StrPath + ) -> SyncResult: + """Recover original state into a new destination while retaining the token.""" + raise NotImplementedError + + def release_changes(self, token: RecoveryToken) -> None: + """Release owned recovery material explicitly, without changing the checkout.""" + raise NotImplementedError + def ensure_dir(self, *args: t.Any, **kwargs: t.Any) -> bool: """Assure destination path exists. If not, create directories.""" if self.path.exists(): diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py index 2d14c3b5e..fc58dc8c6 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -19,26 +19,64 @@ import contextlib import dataclasses +import json import logging +import os import pathlib import re import typing as t from urllib import parse as urlparse from libvcs import exc -from libvcs._internal.run import reject_option_like +from libvcs._internal import preservation +from libvcs._internal.run import ProgressCallbackProtocol +from libvcs._internal.subprocess import SubprocessCommand from libvcs._internal.types import StrPath from libvcs.cmd.git import Git +from libvcs.cmd.git_filter import Auto, GitFilterInput, filter_specs from libvcs.sync.base import ( BaseSync, + RecoveryToken, + SyncConflict, + SyncPolicy, SyncResult, + SyncTarget, VCSLocation, + WorkingCopyPosition, convert_pip_url as base_convert_pip_url, ) logger = logging.getLogger(__name__) +@dataclasses.dataclass(frozen=True) +class GitOptions: + """Backend-specific options for Git synchronization.""" + + depth: int | None = None + filter: GitFilterInput | None = None + tls_verify: bool = True + + def __post_init__(self) -> None: + """Validate and snapshot Git options without running Git.""" + if self.depth is not None and ( + isinstance(self.depth, bool) + or not isinstance(self.depth, int) + or self.depth < 1 + ): + msg = "depth must be a positive integer or None" + raise ValueError(msg) + if not isinstance(self.tls_verify, bool): + msg = "tls_verify must be a boolean" + raise TypeError(msg) + specs = filter_specs(self.filter) + if specs == ("auto",): + canonical_filter: GitFilterInput | None = Auto() + else: + canonical_filter = specs or None + object.__setattr__(self, "filter", canonical_filter) + + class GitStatusParsingException(exc.LibVCSException): """Raised when git status output is not in the expected format.""" @@ -240,17 +278,17 @@ class GitSync(BaseSync): schemes = ("git+http", "git+https", "git+file") cmd: Git _remotes: GitSyncRemoteDict + options_type = GitOptions def __init__( self, *, url: str, path: StrPath, + options: GitOptions | None = None, remotes: GitRemotesArgs = None, - git_shallow: bool = False, - tls_verify: bool = False, - depth: int | None = None, - **kwargs: t.Any, + progress_callback: ProgressCallbackProtocol | None = None, + rev: str | None = None, ) -> None: """Local git repository. @@ -259,17 +297,8 @@ def __init__( url : str URL of repo - git_shallow : bool - Clone with history truncated to the latest commit (``--depth 1``, - default False) - - depth : int, optional - Clone with history truncated to ``depth`` commits - (``git clone --depth N``). Takes precedence over ``git_shallow``. - Default None (full clone). - - tls_verify : bool - Should certificate for https be checked (default False) + options : GitOptions, optional + Git-specific clone and transport configuration. Examples -------- @@ -306,9 +335,12 @@ def __init__( } ) """ - self.git_shallow = git_shallow - self.tls_verify = tls_verify - self.depth = depth + if options is None: + options = GitOptions() + elif not isinstance(options, GitOptions): + msg = "options must be a GitOptions instance" + raise TypeError(msg) + self.options = options self._remotes: GitSyncRemoteDict @@ -340,7 +372,12 @@ def __init__( fetch_url=url, push_url=url, ) - super().__init__(url=url, path=path, **kwargs) + super().__init__( + url=url, + path=path, + progress_callback=progress_callback, + rev=rev, + ) self.cmd = Git(path=path, progress_callback=self.progress_callback) @@ -413,23 +450,27 @@ def obtain(self, *args: t.Any, **kwargs: t.Any) -> None: url = self.url self.log.info("Cloning.") - # An explicit depth wins; otherwise git_shallow keeps the depth-1 - # behavior, and neither means a full clone. - clone_depth: int | None - if self.depth is not None: - clone_depth = self.depth - elif self.git_shallow: - clone_depth = 1 - else: - clone_depth = None self.cmd.clone( url=url, progress=True, - depth=clone_depth, - config={"http.sslVerify": False} if self.tls_verify else None, + depth=self.options.depth, + _filter=self.options.filter, + config={"http.sslVerify": False} if not self.options.tls_verify else None, log_in_real_time=True, + check_returncode=True, ) + submodule_filter = self.options.filter + if isinstance(self.options.filter, Auto): + tracked = self.cmd.run(["ls-files", "--stage", "-z"], check_returncode=True) + if any(entry.startswith("160000 ") for entry in tracked.split("\0")): + msg = ( + "git_filter: auto cannot be applied to repository submodules; " + "the parent clone remains at the destination" + ) + raise ValueError(msg) + submodule_filter = None + self.log.info("Initializing submodules.") self.cmd.submodule.init( log_in_real_time=True, @@ -437,282 +478,960 @@ def obtain(self, *args: t.Any, **kwargs: t.Any) -> None: self.cmd.submodule.update( init=True, recursive=True, + depth=self.options.depth, + _filter=submodule_filter, + config=({"http.sslVerify": False} if not self.options.tls_verify else None), log_in_real_time=True, ) self.set_remotes(overwrite=True) - def update_repo( - self, - set_remotes: bool = False, - *args: t.Any, - **kwargs: t.Any, - ) -> SyncResult: - """Pull latest changes from git remote. - - .. todo:: - - Honor ``depth`` on update by deepening or unshallowing the existing - checkout when the requested depth differs from what is on disk. - Tracked in https://github.com/vcs-python/libvcs/issues/532. Edges to - handle: ``git fetch --depth N`` against a full checkout truncates it - to shallow, and ``git fetch --unshallow`` against a complete repo is - a fatal error (guard with ``git rev-parse --is-shallow-repository``). - - Parameters - ---------- - set_remotes : bool - If True, configure remotes before updating. - - Returns - ------- - SyncResult - Result of the sync operation, with any errors recorded. - """ - result = SyncResult() - self.ensure_dir() - - if not pathlib.Path(self.path / ".git").is_dir(): + def _read_git(self, args: list[str], *, path: pathlib.Path | None = None) -> str: + """Read native metadata without mixing diagnostics into machine output.""" + command = SubprocessCommand( + ["git", "-c", "protocol.allow=never", *args], + cwd=path or self.path, + env={ + **{ + key: value + for key, value in os.environ.items() + if key != "GIT_CONFIG" + }, + "GIT_NO_LAZY_FETCH": "1", + "GIT_TERMINAL_PROMPT": "0", + }, + ) + completed = command.run(capture_output=True, check=False) + if completed.returncode: + raise exc.CommandError( + cmd=["git", *args], + returncode=completed.returncode, + output=os.fsdecode(completed.stderr), + ) + return os.fsdecode(completed.stdout) + + def _oid(self, ref: str) -> str: + return self._read_git( + ["rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"] + ).strip() + + def resolve_target(self, target: SyncTarget | None = None) -> WorkingCopyPosition: + """Resolve available local refs without fetching or changing the checkout.""" + if target is None and self.rev is not None: + target = SyncTarget(rev=self.rev) + current = self.get_position() + if target is None: + if not current.follows: + return current try: - self.obtain() - except exc.CommandError as e: - self.log.exception("Failed to obtain repository") - result.add_error("obtain", str(e), exception=e) - return result - return self.update_repo(set_remotes=set_remotes) - - if set_remotes: + oid = self._oid("@{upstream}") + except exc.CommandError: + oid = self._oid(f"refs/remotes/origin/{current.ref_name}") + return dataclasses.replace(current, revision=oid) + if target.branch is not None: + branch = target.branch + self._read_git(["check-ref-format", f"refs/heads/{branch}"]) + remote = target.remote or "origin" try: - self.set_remotes(overwrite=True) - except exc.CommandError as e: - self.log.exception("Failed to set remotes") - result.add_error("set-remotes", str(e), exception=e) - return result - - # Get requested revision or tag - url, git_tag = self.url, getattr(self, "rev", None) - - if git_tag: + oid = self._oid(f"refs/remotes/{remote}/{branch}") + except exc.CommandError: + if target.remote is not None: + raise + oid = self._oid(f"refs/heads/{branch}") + return WorkingCopyPosition(oid, branch, "branch", follows=True) + if target.tag is not None: + return WorkingCopyPosition( + self._oid(f"refs/tags/{target.tag}"), target.tag, "tag", follows=False + ) + if target.commit is not None: + oid = self._oid(target.commit) + return WorkingCopyPosition(oid, oid, "commit", follows=False) + rev = str(target.rev) + for prefix, kind in (("refs/heads/", "branch"), ("refs/tags/", "tag")): try: - reject_option_like(str(git_tag), name="rev") - except exc.LibVCSException as e: - result.add_error("rev", str(e), exception=e) - return result - - if not git_tag: - self.log.debug("No git revision set, defaulting to origin/master") + self._oid(prefix + rev) + except exc.CommandError: + continue + return self.resolve_target( + SyncTarget(branch=rev, remote=target.remote) + if kind == "branch" + else SyncTarget(tag=rev) + ) + if rev.startswith("origin/"): + return self.resolve_target( + SyncTarget(branch=rev.removeprefix("origin/"), remote="origin") + ) + oid = self._oid(rev) + return WorkingCopyPosition(oid, oid, "commit", follows=False) + + def _dirty_paths(self) -> tuple[str, ...]: + entries = iter( + self._read_git( + ["status", "--porcelain=v2", "-z", "--untracked-files=all"] + ).split("\0") + ) + paths = [] + for entry in entries: + if not entry: + continue + kind = entry[0] + if kind in "12u": + fields = entry.split(" ", {"1": 8, "2": 9, "u": 10}[kind]) + if len(fields) != {"1": 9, "2": 10, "u": 11}[kind]: + raise GitStatusParsingException(entry) + if kind == "u" or fields[2] != "N...": + msg = "unmerged index or submodule changes are unsupported" + raise ValueError(msg) + paths.append(fields[-1]) + if kind == "2": + original = next(entries, "") + if not original: + raise GitStatusParsingException(entry) + paths.append(original) + elif kind == "?": + paths.append(entry[2:]) + else: + raise GitStatusParsingException(entry) + return tuple(paths) + + def is_dirty(self) -> bool: + """Read tracked and untracked changes; ignored output is not ordinary dirt.""" + return bool(self._dirty_paths()) + + def _store(self) -> preservation.RecoveryStore: + common = pathlib.Path( + self._read_git( + ["rev-parse", "--path-format=absolute", "--git-common-dir"] + ).strip() + ) + return preservation.RecoveryStore(self.path, "git", common) + + def _native_precondition(self) -> None: + root = pathlib.Path(self._read_git(["rev-parse", "--show-toplevel"]).strip()) + if root != self.path.absolute(): + msg = "Git synchronization requires the working-copy root" + raise ValueError(msg) + for name in ( + "index.lock", + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "rebase-merge", + "rebase-apply", + "sequencer", + ): + path = pathlib.Path( + self._read_git(["rev-parse", "--git-path", name]).strip() + ) + if not path.is_absolute(): + path = self.path / path + if path.exists(): + msg = f"native Git activity prevents synchronization: {name}" + raise ValueError(msg) + + def _precondition(self) -> tuple[str, ...]: + self._native_precondition() + dirty = self._dirty_paths() + tracked = self._read_git(["ls-files", "--stage", "-z"]) + submodules = { + self.path / entry.split("\t", 1)[1] + for entry in tracked.split("\0") + if entry.startswith("160000 ") + } + if submodules and dirty: + msg = "dirty submodule scope cannot be preserved" + raise ValueError(msg) + for directory, dirs, files in os.walk(self.path, followlinks=False): + current = pathlib.Path(directory) + if current in submodules: + dirs[:] = [] + continue + if current != self.path and any( + name in dirs or name in files for name in (".git", ".hg", ".svn") + ): + msg = "nested repository prevents synchronization" + raise ValueError(msg) + dirs[:] = [name for name in dirs if name not in (".git", ".hg", ".svn")] + return dirty + + def _guard_submodules(self, revision: str) -> None: + """Guard initialized descendants before recursive checkout writes.""" + tree = self._read_git(["ls-tree", "-r", "-z", revision]) + for entry in tree.split("\0"): + if not entry.startswith("160000 "): + continue + metadata, relative = entry.split("\t", 1) + desired = metadata.split(" ")[2] + path = preservation.safe_path(self.path / relative) + if not (path / ".git").exists(): + continue + child = GitSync(url=self.url, path=path, options=self.options) + if child._precondition(): + msg = f"dirty submodule prevents recursive update: {relative}" + raise ValueError(msg) + original = child.get_position() + config = {"http.sslVerify": False} if not self.options.tls_verify else None try: - symref = self.cmd.symbolic_ref( - name="HEAD", - short=True, - check_returncode=True, - ) - git_tag = symref.rstrip() if symref else "origin/master" - except exc.CommandError as e: - self.log.exception("Failed to determine current branch") - result.add_error("symbolic-ref", str(e), exception=e) - return result - self.log.debug("git_tag: %s", git_tag) - - self.log.info("Updating to '%s'.", git_tag) + child._oid(desired) + except exc.CommandError: + child.cmd.run(["fetch"], config=config, check_returncode=True) + try: + child._oid(desired) + except exc.CommandError: + remote = "origin" + if original.ref_kind == "branch": + with contextlib.suppress(exc.CommandError): + remote = child._read_git( + [ + "config", + "--get", + f"branch.{original.ref_name}.remote", + ] + ).strip() + # Native submodule update also fetches a pinned commit + # directly when no advertised ref reaches it. + child.cmd.run( + ["fetch", "--", remote, desired], + config=config, + check_returncode=True, + ) + child._oid(desired) + target = WorkingCopyPosition(desired, desired, "commit", follows=False) + try: + child._ignored_collisions(original, target, dirty=False) + child._guard_submodules(desired) + except ValueError as error: + msg = f"submodule {relative}: {error}" + raise ValueError(msg) from error + + def _sync_submodules(self) -> None: + self._guard_submodules(self._oid("HEAD")) + self.cmd.submodule.update( + recursive=True, + init=True, + _filter=self.options.filter, + config={"http.sslVerify": False} if not self.options.tls_verify else None, + log_in_real_time=True, + check_returncode=True, + ) - # Get head sha + def _ignored_collisions( + self, original: WorkingCopyPosition, target: WorkingCopyPosition, *, dirty: bool + ) -> None: + ignored = self._read_git( + ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"] + ).split("\0") + changed = self._read_git( + [ + "diff", + "--name-only", + "--diff-filter=ACMRT", + "--no-renames", + "-z", + original.revision, + target.revision, + "--", + ] + ).split("\0") + if dirty: + changed += self._read_git( + ["ls-tree", "--name-only", "-r", "-z", original.revision] + ).split("\0") + for path in ignored: + if path and any( + name + and ( + path == name + or path.startswith(name + "/") + or name.startswith(path + "/") + ) + for name in changed + ): + msg = f"ignored path obstructs target: {path}" + raise ValueError(msg) + + def _owned_stash(self, token: RecoveryToken, record: preservation.Record) -> str: + ref = f"refs/libvcs/preserve/{token.id}" + saved = record["native"].get("oid") + if saved: + if self._oid(ref) != saved: + msg = "owned Git preservation ref changed" + raise ValueError(msg) + candidates = [saved] + else: + entries = self._read_git(["stash", "list", "--format=%H%x00%gs%x00"]).split( + "\0" + ) + candidates = [ + entries[index].strip() + for index in range(0, len(entries) - 1, 2) + if entries[index + 1].endswith(": " + record["marker"]) + ] + if len(candidates) != 1: + msg = "owned Git stash is missing or ambiguous" + raise ValueError(msg) + oid = candidates[0] try: - head_sha = self.cmd.rev_list( - commit="HEAD", - max_count=1, - check_returncode=True, - ).strip() - except exc.CommandError as e: - self.log.exception("Failed to get the hash for HEAD") - result.add_error("rev-list-head", str(e), exception=e) - return result - - self.log.debug("head_sha: %s", head_sha) - - # If a remote ref is asked for, which can possibly move around, - # we must always do a fetch and checkout. - show_ref_output = self.cmd.show_ref(pattern=git_tag, check_returncode=False) - self.log.debug("show_ref_output: %s", show_ref_output) - is_remote_ref = "remotes" in show_ref_output - self.log.debug("is_remote_ref: %s", is_remote_ref) - - # show-ref output is in the form " refs/remotes//" - # we must strip the remote from the tag. + pinned = self._oid(ref) + except exc.CommandError: + pinned = None + if pinned is not None and pinned != oid: + msg = "owned Git preservation ref changed" + raise ValueError(msg) + subject = self._read_git(["show", "-s", "--format=%s", oid]).strip() + if ( + not subject.endswith(": " + record["marker"]) + or self._oid(f"{oid}^1") != record["original"]["revision"] + ): + msg = "Git stash does not match capture identity" + raise ValueError(msg) + return str(oid) + + def _pin_stash(self, token: RecoveryToken, record: preservation.Record) -> str: + oid = self._owned_stash(token, record) + ref = f"refs/libvcs/preserve/{token.id}" try: - git_remote_name = self.get_current_remote_name() - except (exc.CommandError, GitNoBranchFound, GitRemoteSetError) as e: - self.log.exception("Failed to determine remote name") - result.add_error("remote-name", str(e), exception=e) - return result - - if f"refs/remotes/{git_tag}" in show_ref_output: - m = re.match( - r"^[0-9a-f]{40} refs/remotes/" - r"(?P[^/]+)/" - r"(?P.+)$", - show_ref_output, - re.MULTILINE, + existing = self._oid(ref) + except exc.CommandError: + self.cmd.run( + ["update-ref", ref, oid, "0" * len(oid)], check_returncode=True ) - if m is None: - ref_err = GitRemoteRefNotFound( - git_tag=git_tag, - ref_output=show_ref_output, - ) - self.log.error("Remote ref not found: '%s'", git_tag) - result.add_error( - "remote-ref-not-found", - str(ref_err), - exception=ref_err, - ) - return result - git_remote_name = m.group("git_remote_name") - git_tag = m.group("git_tag") - self.log.debug("git_remote_name: %s", git_remote_name) - self.log.debug("git_tag: %s", git_tag) - - # This will fail if the tag does not exist (it probably has not - # been fetched yet). - # - # When the ref is local, use the fully-qualified refs/heads/ path - # if available to avoid ambiguity with paths (e.g. a branch named - # "notes" when a directory "notes/" also exists). - if is_remote_ref: - rev_list_commit = git_remote_name + "/" + git_tag - elif f"refs/heads/{git_tag}" in show_ref_output: - rev_list_commit = f"refs/heads/{git_tag}" else: - rev_list_commit = git_tag + if existing != oid: + msg = "owned preservation ref has a different object" + raise ValueError(msg) + record["native"] = {"oid": oid, "ref": ref} + return oid + + def _conflicts(self, oid: str) -> tuple[SyncConflict, ...]: + unmerged = self._read_git(["ls-files", "--unmerged", "-z"]) + paths: dict[str, str] = {} + for entry in unmerged.split("\0"): + if not entry: + continue + metadata, path = entry.split("\t", 1) + blob = metadata.split(" ")[1] + binary = "\0" in self._read_git(["cat-file", "blob", blob]) + paths[path] = "binary" if binary else paths.get(path, "text") try: - error_code = 0 - tag_sha = self.cmd.rev_list( - commit=rev_list_commit, - max_count=1, - ).strip() - - except exc.CommandError as e: - # Intentionally not recorded in SyncResult: the ref may not be - # fetched yet. The error_code drives the fetch-then-checkout - # logic below. Ambiguity errors are prevented by the - # refs/heads/ disambiguation above. - error_code = e.returncode if e.returncode is not None else 0 - tag_sha = "" - self.log.debug("tag_sha: %s", tag_sha) - - # Is the hash checkout out what we want? - somethings_up = (error_code, is_remote_ref, tag_sha != head_sha) - if all(not x for x in somethings_up): - self.log.info("Already up-to-date.") - return result + unknown_tree = self._oid(f"{oid}^3") + except exc.CommandError: + pass + else: + unknown = self._read_git( + ["ls-tree", "--name-only", "-r", "-z", unknown_tree] + ).split("\0") + tracked = set(self._read_git(["ls-files", "-z"]).split("\0")) + paths.update( + { + path: "untracked-obstruction" + for path in unknown + if path and path in tracked + } + ) + return tuple( + SyncConflict(path, reason) for path, reason in sorted(paths.items()) + ) + def list_recoveries(self) -> tuple[SyncResult, ...]: + """Find retained and interrupted saves without resuming an update.""" + store = self._store() + with store.lock(): + results = store.discover() + for result in results: + assert result.recovery is not None + if any(error.step == "recovery-record" for error in result.errors): + continue + try: + self._owned_stash(result.recovery, store.read(result.recovery)) + except ( + exc.LibVCSException, + OSError, + ValueError, + KeyError, + TypeError, + ) as error: + result.add_error("recovery-material", str(error), error) + return results + + def _retained_refusal(self, store: preservation.RecoveryStore) -> SyncResult | None: + """Inspect interrupted owners while the caller holds the common lock.""" + result = SyncResult() try: - process = self.cmd.fetch(log_in_real_time=True, check_returncode=True) - except exc.CommandError as e: - self.log.exception("Failed to fetch repository '%s'", url) - result.add_error("fetch", str(e), exception=e) + active = store.repository / ".libvcs-preserve-active.json" + if active.exists(): + preservation.safe_path(active) + owner = json.loads(active.read_text()) + owner_store = preservation.RecoveryStore( + pathlib.Path(owner["source"]), "git", store.repository + ) + owner_token = RecoveryToken(**owner["token"]) + result.recovery = owner_token + result.update_state = "unknown" + result.preservation_state = "unknown" + owner_record = owner_store.read(owner_token) + if owner_record["phase"] not in preservation.TERMINAL: + return owner_store.snapshot(owner_token, owner_record) + result = SyncResult() + for retained in store.discover(): + assert retained.recovery is not None + if any(error.step == "recovery-record" for error in retained.errors): + return retained + result = retained + retained_record = store.read(retained.recovery) + if retained_record["phase"] not in preservation.TERMINAL: + return retained + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + TypeError, + ) as error: + result.add_error("precondition", str(error), error) return result + return None - if is_remote_ref: - # Check if stash is needed + def _fast_forward_target(self, target: WorkingCopyPosition) -> WorkingCopyPosition: + """Retain local commits and reject divergence before any checkout writes.""" + if target.follows: try: - process = self.cmd.status(porcelain=True, untracked_files="no") - except exc.CommandError as e: - self.log.exception("Failed to get the status") - result.add_error("status", str(e), exception=e) - return result - need_stash = len(process) > 0 - - # If not in clean state, stash changes in order to be able - # to be able to perform git pull --rebase - if need_stash: - # If Git < 1.7.6, uses --quiet --all - git_stash_save_options = "--quiet" - try: - process = self.cmd.stash.save(message=git_stash_save_options) - except exc.CommandError as e: - self.log.exception("Failed to stash changes") - result.add_error("stash-save", str(e), exception=e) - return result + local = self._oid(f"refs/heads/{target.ref_name}") + except exc.CommandError: + return target + if self._ancestor(target.revision, local): + return dataclasses.replace(target, revision=local) + if not self._ancestor(local, target.revision): + msg = "target diverges; fast-forward required" + raise ValueError(msg) + return target + + def create_worktree( + self, + destination: StrPath, + *, + target: SyncTarget, + detach: bool = False, + lock: bool = False, + lock_reason: str | None = None, + set_remotes: bool = False, + ) -> SyncResult: + """Create a linked checkout at its resolved target under shared ownership. - # Checkout the remote branch - try: - process = self.cmd.checkout( - branch=git_tag, + The destination must be absent. Branches advance only by fast-forward, + retain local commits, and cannot be checked out elsewhere. Parent working + files remain untouched. Fetch, creation, submodules, and lock metadata + share one repository lock; callers must exclude external native writers. + + Native failures may leave refs or a partial checkout. An unknown update + state means creation began but did not report completion; a completed + update with errors means subsequent submodule or lock setup failed. + ``lock_reason`` implies ``lock=True``. + """ + result = SyncResult() + step = "precondition" + try: + store = self._store() + with store.lock(): + refused = self._retained_refusal(store) + if refused is not None: + return refused + self._native_precondition() + dest = preservation.safe_path(pathlib.Path(destination)) + # Native add -B can move a branch before rejecting an occupied path. + if dest.exists(): + msg = f"worktree destination already exists: {dest}" + raise ValueError(msg) # noqa: TRY301 - refuse before native mutation + if set_remotes: + step = "set-remotes" + self.set_remotes(overwrite=True) + step = "fetch" + self.cmd.fetch( + _all=True, + prune=True, + config={"http.sslVerify": False} + if not self.options.tls_verify + else None, check_returncode=True, ) - except exc.CommandError as e: - self.log.exception("Failed to checkout tag: '%s'", git_tag) - result.add_error("checkout", str(e), exception=e) - return result - - # Rebase changes from the remote branch - try: - process = self.cmd.rebase(upstream=git_remote_name + "/" + git_tag) - except exc.CommandError as e: - if any(msg in str(e) for msg in ["invalid_upstream", "Aborting"]): - self.log.exception("Invalid upstream remote. Rebase aborted.") - result.add_error("rebase", str(e), exception=e) - return result + step = "target" + resolved = self.resolve_target(target) + args = ["worktree", "add"] + if resolved.follows and not detach: + resolved = self._fast_forward_target(resolved) + try: + self._oid(f"refs/heads/{resolved.ref_name}") + except exc.CommandError: + args += ["-b", resolved.ref_name] + else: + args += ["-B", resolved.ref_name] else: - # Rebase failed: Restore previous state. - with contextlib.suppress(exc.CommandError): - self.cmd.rebase(abort=True) - if need_stash: - with contextlib.suppress(exc.CommandError): - self.cmd.stash.pop(index=True, quiet=True) + args += ["--detach"] + args += ["--", str(dest), resolved.revision] + step = "worktree-add" + result.update_state = "unknown" + self.cmd.run( + args, + config={"http.sslVerify": False} + if not self.options.tls_verify + else None, + check_returncode=True, + ) + result.update_state = "completed" + if lock or lock_reason is not None: + step = "worktree-lock" + lock_args = ["worktree", "lock"] + if lock_reason is not None: + lock_args += ["--reason", lock_reason] + self.cmd.run([*lock_args, "--", str(dest)], check_returncode=True) + step = "submodule-update" + GitSync( + url=self.url, path=dest, options=self.options + )._sync_submodules() + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + TypeError, + ) as error: + result.add_error(step, str(error), error) + return result + + def update_repo( + self, + set_remotes: bool = False, + *args: t.Any, + target: SyncTarget | None = None, + policy: SyncPolicy | None = None, + detach: bool = False, + **kwargs: t.Any, + ) -> SyncResult: + """Follow targets by fast-forward; abort on dirt unless explicitly permitted. + + Preserve retains an owned stash, even after indexed restoration. Recovery + needs the retained local object database. Callers must exclude other VCS + writers and editors throughout this operation. - self.log.exception( - f"\nFailed to rebase in: '{self.path}'.\n" - "You will have to resolve the conflicts manually", + ``detach=True`` checks out the resolved target without attaching its + branch. Resolution happens after fetching, under the same ownership lock. + """ + result = SyncResult() + policy = policy or SyncPolicy() + step = "precondition" + try: + if target is None and self.rev is not None: + target = SyncTarget(rev=self.rev) + created = not (self.path / ".git").exists() + if created: + step = "obtain" + self.obtain() + store = self._store() + with store.lock(): + active = store.repository / ".libvcs-preserve-active.json" + refused = self._retained_refusal(store) + if refused is not None: + return refused + dirty = self._precondition() + original = self.get_position() + step = "target" + # Existing keep/warn checkouts inspect metadata without fetching. + if not created and policy.drift != "follow": + resolved = self.resolve_target(target) + if original.revision != resolved.revision: + if policy.drift == "warn": + logger.warning( + "configured Git target drifted", + extra={ + "vcs_event": "target_drift", + "vcs_type": "git", + "vcs_repo_path": str(self.path), + }, + ) + return result + return result + if dirty and policy.dirty == "abort": + result.add_error("dirty", "working copy has local changes") + return result + if set_remotes: + step = "set-remotes" + self.set_remotes(overwrite=True) + step = "fetch" + self.cmd.fetch( + _all=True, + prune=True, + config={"http.sslVerify": False} + if not self.options.tls_verify + else None, + check_returncode=True, + ) + step = "target" + resolved = self.resolve_target(target) + if detach and resolved.follows: + resolved = dataclasses.replace( + resolved, + ref_name=resolved.revision, + ref_kind="commit", + follows=False, ) - result.add_error("rebase", str(e), exception=e) + if not self._drifted(original, resolved): + if not dirty: + step = "submodule-update" + self._sync_submodules() return result - - if need_stash: - try: - process = self.cmd.stash.pop(index=True, quiet=True) - except exc.CommandError: - # Stash pop --index failed: Try again dropping the index - with contextlib.suppress(exc.CommandError): - self.cmd.reset(hard=True, quiet=True) + resolved = self._fast_forward_target(resolved) + target_tree = self._read_git(["ls-tree", "-r", "-z", resolved.revision]) + if dirty and any( + entry.startswith("160000 ") for entry in target_tree.split("\0") + ): + msg = "target contains unsupported submodules" + raise ValueError(msg) # noqa: TRY301 - reject before capture + self._ignored_collisions(original, resolved, dirty=bool(dirty)) + if not dirty: + step = "submodule-preflight" + self._guard_submodules(resolved.revision) + token: RecoveryToken | None = None + record: preservation.Record | None = None + if dirty and policy.dirty == "preserve": + step = "capture" + original_data = dataclasses.asdict(original) + original_data["status_paths"] = dirty + original_data["config"] = self._read_git( + ["config", "--local", "--list", "-z"] + ) + token, record = store.create( + original=original_data, target=dataclasses.asdict(resolved) + ) + result.recovery = token + result.preservation_state = "unknown" try: - process = self.cmd.stash.pop(quiet=True) - except exc.CommandError as e: - # Stash pop failed: Restore previous state. - with contextlib.suppress(exc.CommandError): - self.cmd.reset( - pathspec=head_sha, - hard=True, - quiet=True, - ) - with contextlib.suppress(exc.CommandError): - self.cmd.stash.pop(index=True, quiet=True) - self.log.exception( - f"\nFailed to rebase in: '{self.path}'.\n" - "You will have to resolve the " - "conflicts manually", + preservation.atomic_record( + active, + { + "source": str(store.source), + "token": dataclasses.asdict(token), + }, + ) + self.cmd.run( + [ + "stash", + "push", + "--include-untracked", + "--message", + record["marker"], + ], + check_returncode=True, ) - result.add_error("stash-pop", str(e), exception=e) + self._pin_stash(token, record) + result.preservation_state = "saved" + store.phase(token, record, "sealed") + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + ) as error: + result.add_error(step, str(error), error) + try: + self._pin_stash(token, record) + result.preservation_state = "saved" + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + ) as inspection: + result.add_error( + "capture-inspection", str(inspection), inspection + ) + self._finish_git(store, token, record, result) return result + try: + step = "update" + if token is not None and record is not None: + store.phase(token, record, "updating") + if dirty and policy.dirty == "discard": + self.cmd.run(["reset", "--hard", "HEAD"], check_returncode=True) + self.cmd.run(["clean", "-fd"], check_returncode=True) + step = "update" + result.update_state = "unknown" + if resolved.follows: + if ( + original.ref_kind != "branch" + or original.ref_name != resolved.ref_name + ): + try: + self._oid(f"refs/heads/{resolved.ref_name}") + except exc.CommandError: + self.cmd.run( + [ + "checkout", + "--no-overwrite-ignore", + "-b", + resolved.ref_name, + resolved.revision, + "--", + ], + check_returncode=True, + ) + else: + self.cmd.run( + [ + "checkout", + "--no-overwrite-ignore", + resolved.ref_name, + "--", + ], + check_returncode=True, + ) + self.cmd.run( + [ + "merge", + "--ff-only", + "--no-overwrite-ignore", + resolved.revision, + ], + check_returncode=True, + ) + else: + self.cmd.run( + [ + "checkout", + "--no-overwrite-ignore", + "--detach", + resolved.revision, + ], + check_returncode=True, + ) + result.update_state = "completed" + if not dirty: + step = "submodule-update" + self._sync_submodules() + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + ) as error: + if result.update_state == "unknown": + result.update_state = "failed" + result.add_error(step, str(error), error) + if token is not None and record is not None: + try: + store.phase(token, record, "inspecting") + self.cmd.run( + ["stash", "apply", "--index", record["native"]["oid"]], + check_returncode=True, + ) + result.preservation_state = "restored" + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + ) as error: + result.preservation_state = "failed" + result.add_error("restore", str(error), error) + try: + result.conflicts = self._conflicts(record["native"]["oid"]) + if result.conflicts: + result.preservation_state = "conflicted" + result.add_error( + "conflicts", + "indexed restoration has unresolved conflicts", + ) + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + ) as error: + result.preservation_state = "unknown" + result.add_error("inspection", str(error), error) + self._finish_git(store, token, record, result) + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + TypeError, + ) as error: + result.add_error(step, str(error), error) + return result - else: - try: - process = self.cmd.checkout( - branch=git_tag, - check_returncode=True, + @staticmethod + def _drifted(original: WorkingCopyPosition, target: WorkingCopyPosition) -> bool: + return ( + original.revision != target.revision + or ( + target.follows + and ( + original.ref_kind != "branch" + or original.ref_name != target.ref_name ) - except exc.CommandError as e: - self.log.exception("Failed to checkout tag: '%s'", git_tag) - result.add_error("checkout", str(e), exception=e) - return result + ) + or (not target.follows and original.follows) + ) + def _ancestor(self, older: str, newer: str) -> bool: try: - self.cmd.submodule.update(recursive=True, init=True, log_in_real_time=True) - except exc.CommandError as e: - self.log.exception("Failed to update submodules") - result.add_error("submodule-update", str(e), exception=e) + self._read_git(["merge-base", "--is-ancestor", older, newer]) + except exc.CommandError as error: + if error.returncode != 1: + raise + return False + return True + + @staticmethod + def _finish_git( + store: preservation.RecoveryStore, + token: RecoveryToken, + record: preservation.Record, + result: SyncResult, + ) -> None: + try: + store.finish(token, record, result) + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + TypeError, + ) as error: + result.add_error("publication", str(error), error) + + def recover_changes( + self, token: RecoveryToken, *, destination: StrPath + ) -> SyncResult: + """Recover the original base and indexed changes using retained local objects. + + The destination is independent; missing source objects fail recovery without + fetching from a remote. The token remains available for another recovery. + """ + result = SyncResult(recovery=token, preservation_state="unknown") + try: + store = self._store() + with store.lock(): + record = store.read(token) + store.validate_source(record) + dest = store.destination(destination) + oid = self._owned_stash(token, record) + # Verify the full object closure offline before any destination write. + self._read_git(["rev-list", "--objects", "--missing=error", oid]) + self._read_git(["init", str(dest)], path=dest.parent) + self._read_git( + [ + "-c", + "protocol.file.allow=always", + "fetch", + "--no-tags", + str(store.repository), + oid, + ], + path=dest, + ) + original = record["original"] + checkout = ["checkout", "--no-overwrite-ignore"] + if original["ref_kind"] == "branch": + checkout += ["-b", original["ref_name"]] + else: + checkout += ["--detach"] + self._read_git([*checkout, original["revision"]], path=dest) + self._read_git(["stash", "apply", "--index", oid], path=dest) + for item in original["config"].split("\0"): + key, separator, value = item.partition("\n") + if separator and ( + key.startswith(("remote.", f"branch.{original['ref_name']}.")) + ): + self._read_git(["config", "--add", key, value], path=dest) + result.preservation_state = "restored" + except ( + exc.LibVCSException, + OSError, + ValueError, + RuntimeError, + KeyError, + TypeError, + ) as error: + result.preservation_state = "failed" + result.add_error("recovery", str(error), error) return result + def release_changes(self, token: RecoveryToken) -> None: + """Release the exact owned ref and stash entry, retaining ambiguous material.""" + store = self._store() + with store.lock(): + record = store.read(token) + store.validate_source(record) + oid = self._owned_stash(token, record) + entries = self._read_git(["stash", "list", "--format=%H%x00%gs%x00"]).split( + "\0" + ) + matches = [ + index // 2 + for index in range(0, len(entries) - 1, 2) + if entries[index].strip() == oid + and entries[index + 1].endswith(": " + record["marker"]) + ] + if len(matches) > 1: + msg = "ambiguous owned stash entries" + raise ValueError(msg) + if matches: + selector = f"stash@{{{matches[0]}}}" + if self._oid(selector) != oid: + msg = "owned stash entry changed during release" + raise ValueError(msg) + self.cmd.run(["stash", "drop", selector], check_returncode=True) + try: + pinned = self._oid(f"refs/libvcs/preserve/{token.id}") + except exc.CommandError: + pinned = None + if pinned is not None: + if pinned != oid: + msg = "owned preservation ref changed" + raise ValueError(msg) + self.cmd.run( + ["update-ref", "-d", f"refs/libvcs/preserve/{token.id}", oid], + check_returncode=True, + ) + active = store.repository / ".libvcs-preserve-active.json" + if active.exists(): + preservation.safe_path(active) + owner = json.loads(active.read_text()) + if owner.get("token") == dataclasses.asdict(token): + active.unlink() + preservation.flush_directory(store.repository) + store.remove(token) + + def get_position(self) -> WorkingCopyPosition: + """Read HEAD without fetching; detached commits do not follow updates.""" + revision = self.cmd.run(["rev-parse", "--verify", "HEAD"]).strip() + try: + branch = self.cmd.run( + ["symbolic-ref", "--quiet", "HEAD"], + check_returncode=True, + ).strip() + except exc.CommandError as error: + if error.returncode != 1: + raise + else: + return WorkingCopyPosition( + revision, + branch.removeprefix("refs/heads/"), + "branch", + follows=True, + ) + return WorkingCopyPosition(revision, revision, "commit", follows=False) + def remotes(self) -> GitSyncRemoteDict: """Return remotes like git remote -v. @@ -808,7 +1527,7 @@ def set_remote( remote_cmd = self.cmd.remotes.get(remote_name=name, default=None) if remote_cmd is not None and overwrite: - remote_cmd.set_url(url=url, check_returncode=True) + remote_cmd.set_url(url=url, push=push, check_returncode=True) else: self.cmd.remotes.add(name=name, url=url, check_returncode=True) diff --git a/src/libvcs/sync/hg.py b/src/libvcs/sync/hg.py index 5d4fad3e7..16c0f936c 100644 --- a/src/libvcs/sync/hg.py +++ b/src/libvcs/sync/hg.py @@ -10,32 +10,127 @@ from __future__ import annotations +import configparser +import dataclasses +import json import logging +import os import pathlib +import re +import shutil +import stat +import tempfile import typing as t +from collections.abc import Mapping from libvcs import exc +from libvcs._internal import preservation +from libvcs._internal.run import ProgressCallbackProtocol +from libvcs._internal.subprocess import SubprocessCommand from libvcs._internal.types import StrPath from libvcs.cmd.hg import Hg -from .base import BaseSync, SyncResult +from .base import ( + BaseSync, + RecoveryToken, + SyncConflict, + SyncPolicy, + SyncResult, + SyncTarget, + WorkingCopyPosition, +) logger = logging.getLogger(__name__) +@dataclasses.dataclass(frozen=True) +class HgRemote: + """One native Mercurial path with separate inbound and outbound URLs. + + Attributes + ---------- + name : str + Alias in the native ``[paths]`` section. + fetch_url : str + URL used by pull with this alias. + push_url : str | None + Alias-specific push URL; defaults to the fetch URL. + """ + + name: str + fetch_url: str + push_url: str | None = None + + def __post_init__(self) -> None: + """Reject values that could create additional native config entries.""" + if not isinstance(self.name, str): + msg = "Mercurial remote name must be a string" + raise TypeError(msg) + if ( + not self.name + or self.name != self.name.strip() + or self.name.startswith(("%", "#", ";")) + or any(char in self.name for char in "\0\r\n=:[]") + ): + msg = "invalid Mercurial remote name" + raise ValueError(msg) + for field in ("fetch_url", "push_url"): + value = getattr(self, field) + if value is None and field == "push_url": + value = self.fetch_url + if not isinstance(value, str): + msg = f"Mercurial remote {field} must be a string" + raise TypeError(msg) + value = value.removeprefix("hg+") + if not value or value != value.strip() or any(c in value for c in "\0\r\n"): + msg = f"invalid Mercurial remote {field}" + raise ValueError(msg) + object.__setattr__(self, field, value) + + +@dataclasses.dataclass(frozen=True) +class HgOptions: + """Backend-specific options for Mercurial synchronization.""" + + ssh: str | None = None + remote_cmd: str | None = None + pull: bool = False + stream: bool = False + tls_verify: bool = True + + def __post_init__(self) -> None: + """Validate Mercurial option types without running Mercurial.""" + for name in ("ssh", "remote_cmd"): + value = getattr(self, name) + if value is not None and not isinstance(value, str): + msg = f"{name} must be a string or None" + raise TypeError(msg) + if value is not None and "\0" in value: + msg = f"{name} must not contain NUL" + raise ValueError(msg) + for name in ("pull", "stream", "tls_verify"): + if not isinstance(getattr(self, name), bool): + msg = f"{name} must be a boolean" + raise TypeError(msg) + + class HgSync(BaseSync): """Tool to manage a local hg (Mercurial) repository cloned from a remote one.""" bin_name = "hg" schemes = ("hg", "hg+http", "hg+https", "hg+file") cmd: Hg + options_type = HgOptions def __init__( self, *, url: str, path: StrPath, - **kwargs: t.Any, + remotes: Mapping[str, HgRemote | str | Mapping[str, str]] | None = None, + options: HgOptions | None = None, + progress_callback: ProgressCallbackProtocol | None = None, + rev: str | None = None, ) -> None: """Local Mercurial repository. @@ -44,47 +139,1057 @@ def __init__( url : str Mercurial repository URL. """ - super().__init__(url=url, path=path, **kwargs) + if options is None: + options = HgOptions() + elif not isinstance(options, HgOptions): + msg = "options must be an HgOptions instance" + raise TypeError(msg) + self.options = options + super().__init__( + url=url.removeprefix("hg+"), + path=path, + progress_callback=progress_callback, + rev=rev, + ) self.cmd = Hg(path=path, progress_callback=self.progress_callback) + self._implicit_default = remotes is None or "default" not in remotes + self._remotes = {"default": HgRemote("default", self.url)} + if remotes is not None: + for name, remote in remotes.items(): + if isinstance(remote, str): + value = HgRemote(name, remote) + elif isinstance(remote, HgRemote): + if name != remote.name: + msg = "Mercurial remote key and name differ" + raise ValueError(msg) + value = remote + else: + value = HgRemote(name=name, **remote) + self._remotes[name] = value + + def remotes(self) -> dict[str, HgRemote]: + """Read effective native paths, including aliases provided by includes.""" + return self._read_remotes({}) + + def _read_remotes(self, overrides: Mapping[str, HgRemote]) -> dict[str, HgRemote]: + arguments = [] + for name, remote in overrides.items(): + arguments.extend(["--config", f"paths.{name}={remote.fetch_url}"]) + arguments.extend(["--config", f"paths.{name}:pushurl={remote.push_url}"]) + entries = json.loads(self._read_hg([*arguments, "paths", "-Tjson"])) + return { + item["name"]: HgRemote(item["name"], item["url"], item.get("pushurl")) + for item in entries + } + + def set_remotes(self, overwrite: bool = False) -> None: + """Write configured paths atomically, preserving unrelated native config. + + Existing aliases remain unchanged unless ``overwrite`` is true. The + owned paths block must remain last in ``.hg/hgrc``; includes and comments + outside it are retained verbatim. Callers must exclude external writers. + """ + with self._store().lock(): + self._set_remotes(overwrite=overwrite) + + def _set_remotes(self, *, overwrite: bool) -> None: + path = preservation.safe_path(self.path / ".hg" / "hgrc") + current = self.remotes() + desired = dict(self._remotes) + if self._implicit_default and "default" in current: + desired["default"] = HgRemote( + "default", self.url, current["default"].push_url + ) + effective = self._read_remotes(desired) + changes = { + name: remote + for name, remote in desired.items() + if name not in current or (overwrite and current[name] != effective[name]) + } + if not changes: + return + original = path.read_bytes() if path.exists() else b"" + start = b"# libvcs managed paths begin\n" + end = b"# libvcs managed paths end\n" + managed: dict[str, str] = {} + if start in original or end in original: + if original.count(start) != 1 or original.count(end) != 1: + msg = "invalid libvcs paths block in Mercurial config" + raise ValueError(msg) + prefix, block = original.split(start) + if not block.endswith(end): + msg = "libvcs paths block must remain last in Mercurial config" + raise ValueError(msg) + parser = configparser.ConfigParser(interpolation=None, delimiters=("=",)) + parser.optionxform = lambda optionstr: optionstr # type: ignore[method-assign] + parser.read_string(block.removesuffix(end).decode("utf-8")) + managed = dict(parser["paths"]) + else: + prefix = original + for name, remote in changes.items(): + managed[name] = remote.fetch_url + assert remote.push_url is not None + managed[name + ":pushurl"] = remote.push_url + payload = prefix + (b"\n" if prefix and not prefix.endswith(b"\n") else b"") + payload += start + b"[paths]\n" + payload += "".join( + f"{key} = {value}\n" for key, value in managed.items() + ).encode() + payload += end + descriptor, name = tempfile.mkstemp(prefix=".libvcs-hgrc-", dir=path.parent) + temporary = pathlib.Path(name) + try: + with os.fdopen(descriptor, "wb") as stream: + os.fchmod( + stream.fileno(), + stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600, + ) + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + preservation.flush_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) def obtain(self, *args: t.Any, **kwargs: t.Any) -> None: """Clone and update a Mercurial repository to this location.""" + self._obtain(SyncTarget(rev=self.rev) if self.rev is not None else None) + + def _obtain(self, target: SyncTarget | None) -> None: self.cmd.clone( no_update=True, quiet=True, url=self.url, - ) - self.cmd.update( - quiet=True, + ssh=self.options.ssh, + remote_cmd=self.options.remote_cmd, + pull=self.options.pull, + stream=self.options.stream, + insecure=not self.options.tls_verify, check_returncode=True, ) + if target is None: + self.cmd.update(quiet=True, check_returncode=True) + else: + if target.remote is not None: + if ( + target.remote not in self._remotes + and target.remote not in self.remotes() + ): + msg = f"Mercurial remote is unavailable: {target.remote}" + raise ValueError(msg) + self.set_remotes(overwrite=True) + self.cmd.pull( + source=target.remote, + update=False, + ssh=self.options.ssh, + remote_cmd=self.options.remote_cmd, + insecure=not self.options.tls_verify, + check_returncode=True, + ) + resolved = self.resolve_target(target) + self.cmd.run( + ["update", "--quiet", "--rev", resolved.revision], check_returncode=True + ) + if resolved.ref_kind == "bookmark": + self.cmd.run( + ["bookmark", "--force", "--", resolved.ref_name], + check_returncode=True, + ) def get_revision(self) -> str: """Get latest revision of this mercurial repository.""" return self.run(["parents", "--template={rev}"]) - def update_repo(self, *args: t.Any, **kwargs: t.Any) -> SyncResult: - """Pull changes from remote Mercurial repository into this one. + def get_position(self) -> WorkingCopyPosition: + """Read the parent and active bookmark or named branch without pulling.""" + revision, bookmark = self.cmd.run( + ["log", "-r", ".", "-T", "{node}\\0{activebookmark}"], + ).split("\0") + if bookmark: + return WorkingCopyPosition(revision, bookmark, "bookmark", follows=True) + branch = self.cmd.run(["branch"]).strip() + return WorkingCopyPosition(revision, branch, "branch", follows=True) + + def _read_hg(self, args: list[str], *, path: pathlib.Path | None = None) -> str: + command = SubprocessCommand( + ["hg", *args], + cwd=path or self.path, + env={**os.environ, "HGPLAIN": "1", "HGENCODING": "utf-8"}, + ) + completed = command.run(capture_output=True, check=False) + if completed.returncode: + raise exc.CommandError( + cmd=["hg", *args], + returncode=completed.returncode, + output=os.fsdecode(completed.stderr), + ) + return os.fsdecode(completed.stdout) + + def _node(self, selector: str) -> str: + nodes = self._read_hg( + ["log", "--rev", selector, "--template", "{node}\\0"] + ).split("\0") + nodes = [node for node in nodes if node] + if len(nodes) != 1 or re.fullmatch(r"[0-9a-f]{40}", nodes[0]) is None: + msg = "Mercurial target is unavailable or ambiguous" + raise ValueError(msg) + return nodes[0] + + def resolve_target(self, target: SyncTarget | None = None) -> WorkingCopyPosition: + """Resolve available bookmarks, named branches, tags, or changesets locally.""" + if target is None and self.rev is not None: + target = SyncTarget(rev=self.rev) + if ( + target is not None + and target.remote is not None + and target.remote not in self._remotes + and target.remote not in self.remotes() + ): + msg = f"Mercurial remote is unavailable: {target.remote}" + raise ValueError(msg) + if target is None: + current = self.get_position() + name = current.ref_name + if current.ref_kind == "branch": + name = self._read_hg(["log", "-r", ".", "-T", "{branch}"]) + target = SyncTarget(branch=name) + if target.branch is not None: + name = target.branch + bookmarks = json.loads(self._read_hg(["bookmarks", "-Tjson"])) + for bookmark in bookmarks: + if bookmark["bookmark"] == name: + return WorkingCopyPosition( + bookmark["node"], name, "bookmark", follows=True + ) + quoted = name.replace("\\", "\\\\").replace("'", "\\'") + node = self._node(f"heads(branch('{quoted}'))") + return WorkingCopyPosition(node, name, "branch", follows=True) + if target.tag is not None: + for tag in json.loads(self._read_hg(["tags", "-Tjson"])): + if tag["tag"] == target.tag: + return WorkingCopyPosition( + tag["node"], target.tag, "tag", follows=False + ) + msg = "Mercurial tag is unavailable" + raise ValueError(msg) + selector = target.commit if target.commit is not None else str(target.rev) + if ( + target.rev is not None + and not selector.isdecimal() + and re.fullmatch(r"[0-9a-f]{6,40}", selector) is None + ): + for named in (SyncTarget(branch=selector), SyncTarget(tag=selector)): + try: + return self.resolve_target(named) + except (ValueError, exc.CommandError): # noqa: PERF203 - native ref lookup + continue + node = self._node(selector) + return WorkingCopyPosition(node, node, "commit", follows=False) + + def _status(self, *, ignored: bool = False) -> dict[str, str]: + flags = ( + ["--ignored"] + if ignored + else ["--modified", "--added", "--removed", "--deleted", "--unknown"] + ) + entries = self._read_hg(["status", "-0", *flags]).split("\0") + status = {} + for entry in entries: + if not entry: + continue + if len(entry) < 3 or entry[1] != " " or entry[0] not in "MAR!?I": + msg = "invalid Mercurial status output" + raise ValueError(msg) + self._work_path(entry[2:]) + status[entry[2:]] = entry[0] + return status + + def is_dirty(self) -> bool: + """Read native schedules, modifications, missing paths, and unknown files.""" + return bool(self._status()) or self._read_hg( + ["branch"] + ).strip() != self._read_hg(["log", "-r", ".", "-T", "{branch}"]) + + def _work_path(self, relative: str) -> pathlib.Path: + name = pathlib.PurePosixPath(relative) + if not relative or name.is_absolute() or ".." in name.parts: + msg = "unsafe Mercurial working-copy path" + raise ValueError(msg) + path = self.path / name + preservation.safe_path(path.parent) + return path + + def _store(self) -> preservation.RecoveryStore: + return preservation.RecoveryStore(self.path, "hg", self.path / ".hg") + + def _precondition(self) -> dict[str, str]: + if pathlib.Path(self._read_hg(["root"]).strip()) != self.path.absolute(): + msg = "Mercurial synchronization requires the working-copy root" + raise ValueError(msg) + for name in ( + "wlock", + "store/lock", + "shelvedstate", + "rebasestate", + "histedit-state", + "graftstate", + "updatestate", + "merge/state", + "merge/state2", + "sharedpath", + ): + path = self.path / ".hg" / name + if path.exists() or path.is_symlink(): + msg = f"native Mercurial activity or unsupported layout: {name}" + raise ValueError(msg) + parents = self._read_hg(["parents", "--template", "{node}\\0"]).split("\0") + if len([node for node in parents if node]) != 1: + msg = "Mercurial preservation requires exactly one committed parent" + raise ValueError(msg) + if (self.path / ".hgsub").exists() or (self.path / ".hgsubstate").exists(): + msg = "Mercurial subrepositories are outside the preservation scope" + raise ValueError(msg) + for directory, dirs, files in os.walk(self.path, followlinks=False): + if pathlib.Path(directory) != self.path and any( + name in dirs or name in files for name in (".hg", ".git", ".svn") + ): + msg = "nested repository prevents Mercurial synchronization" + raise ValueError(msg) + dirs[:] = [name for name in dirs if name not in (".hg", ".git", ".svn")] + return self._status() + + def _manifest(self, node: str) -> dict[str, preservation.Record]: + return { + entry["path"]: entry + for entry in json.loads( + self._read_hg(["manifest", "--rev", node, "--debug", "-Tjson"]) + ) + } - Returns - ------- - SyncResult - Result of the sync operation, with any errors recorded. + def _ignored_collisions(self, original: str, target: str, *, dirty: bool) -> None: + before, after = self._manifest(original), self._manifest(target) + if ".hgsub" in after or ".hgsubstate" in after: + msg = "target contains unsupported Mercurial subrepositories" + raise ValueError(msg) + writes = {name for name, item in after.items() if before.get(name) != item} + if dirty: + writes.update(before) + for path in self._status(ignored=True): + if any( + path == name + or path.startswith(name + "/") + or name.startswith(path + "/") + for name in writes + ): + msg = f"ignored path obstructs Mercurial update: {path}" + raise ValueError(msg) + + @staticmethod + def _shelf_name(token: RecoveryToken) -> str: + return f"libvcs-{token.id}" + + def _record( + self, store: preservation.RecoveryStore, token: RecoveryToken + ) -> preservation.Record: + record = store.read(token) + original = record["original"] + revision = original.get("revision") + status = original.get("status") + if ( + not isinstance(revision, str) + or re.fullmatch(r"[0-9a-f]{40}", revision) is None + or not isinstance(status, dict) + ): + msg = "invalid Mercurial recovery record identity or status" + raise ValueError(msg) + for name, value in status.items(): + if ( + not isinstance(name, str) + or not isinstance(value, str) + or value not in {"M", "A", "R", "!", "?"} + ): + msg = "invalid Mercurial recovery record status entry" + raise ValueError(msg) + self._work_path(name) + for field in ("missing_added", "recreated"): + values = original.get(field, {}) + if not isinstance(values, dict): + msg = "invalid Mercurial recovery schedule" + raise ValueError(msg) # noqa: TRY004 - persisted record validation + for name, value in values.items(): + if not isinstance(name, str) or not isinstance(value, str): + msg = "invalid Mercurial recovery schedule entry" + raise ValueError(msg) # noqa: TRY004 - persisted record validation + self._work_path(name) + if field == "missing_added": + if status.get(name) != "!": + msg = "missing addition does not match native status" + raise ValueError(msg) + if value: + self._work_path(value) + elif status.get(name) != "R" or not value.isdecimal(): + msg = "recreated removal does not match native status" + raise ValueError(msg) + for field in ("branch", "ref_name", "default"): + if not isinstance(original.get(field), str) or "\0" in original[field]: + msg = "invalid Mercurial recovery record metadata" + raise ValueError(msg) + if ( + original.get("ref_kind") not in ("branch", "bookmark") + or type(original.get("pending_branch")) is not bool + ): + msg = "invalid Mercurial recovery record branch metadata" + raise ValueError(msg) + return record + + def _shelf_files( + self, token: RecoveryToken, record: preservation.Record + ) -> pathlib.Path: + directory = self.path / ".hg" / "shelved" + name = self._shelf_name(token) + for suffix in (".hg", ".patch"): + path = preservation.safe_path(directory / (name + suffix)) + if not path.is_file(): + msg = "retained Mercurial shelf is incomplete or unavailable" + raise ValueError(msg) + patch = (directory / (name + ".patch")).read_bytes() + if ("\n" + record["marker"] + "\n").encode() not in patch or ( + "# Parent " + record["original"]["revision"] + ).encode() not in patch: + msg = "Mercurial shelf does not match capture identity" + raise ValueError(msg) + return directory + + def _needs_shelf(self, record: preservation.Record) -> bool: + return any( + value != "!" and name not in record["original"].get("recreated", {}) + for name, value in record["original"]["status"].items() + ) + + def _capture_recreated( + self, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: preservation.Record, + ) -> None: + recreated = record["original"]["recreated"] + if not recreated: + return + material = store.token_path(token) / "recreated" + material.mkdir(mode=0o700) + for name, item in recreated.items(): + source = self._work_path(name) + destination = material / item + shutil.copy2(source, destination, follow_symlinks=False) + if not destination.is_symlink(): + with destination.open("rb") as stream: + os.fsync(stream.fileno()) + record["native"]["recreated_inventory"] = preservation.inventory(material) + preservation.flush_directory(material) + store.write(token, record) + + def _restore_recreated( + self, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: preservation.Record, + ) -> tuple[SyncConflict, ...]: + recreated = record["original"].get("recreated", {}) + if not recreated: + return () + self._material(store, token, record) + conflicts = [] + status = self._status() + before = self._manifest(record["original"]["revision"]) + after = self._manifest(self._node(".")) + for name, item in recreated.items(): + path = self._work_path(name) + if before.get(name) != after.get(name) or status.get(name) is not None: + conflicts.append(SyncConflict(name, "tree")) + continue + self.cmd.run(["remove", "--", name], check_returncode=True) + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2( + store.token_path(token) / "recreated" / item, + path, + follow_symlinks=False, + ) + return tuple(conflicts) + + def _seal( + self, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: preservation.Record, + ) -> None: + material = store.token_path(token) / "material" + material.mkdir(mode=0o700, exist_ok=False) + if self._needs_shelf(record): + source = self._shelf_files(token, record) + for suffix in (".hg", ".patch", ".shelve"): + path = preservation.safe_path( + source / (self._shelf_name(token) + suffix) + ) + if not path.exists(): + continue + destination = material / path.name + shutil.copy2(path, destination) + with destination.open("rb") as stream: + os.fsync(stream.fileno()) + preservation.flush_directory(material) + record["native"]["inventory"] = preservation.inventory(material) + store.phase(token, record, "sealed") + + def _material( + self, + store: preservation.RecoveryStore, + token: RecoveryToken, + record: preservation.Record, + ) -> pathlib.Path | None: + if record["original"].get("recreated"): + recreated = preservation.safe_path(store.token_path(token) / "recreated") + if not recreated.is_dir() or preservation.inventory(recreated) != record[ + "native" + ].get("recreated_inventory"): + msg = "retained recreated Mercurial files are incomplete or corrupt" + raise ValueError(msg) + if "inventory" in record["native"]: + material = preservation.safe_path(store.token_path(token) / "material") + if ( + not material.is_dir() + or preservation.inventory(material) != record["native"]["inventory"] + ): + msg = "retained Mercurial shelf checksum mismatch" + raise ValueError(msg) + return material + if self._needs_shelf(record): + return self._shelf_files(token, record) + return None + + def _unshelve(self, token: RecoveryToken, *, backup: pathlib.Path) -> None: + self.cmd.run( + [ + "--config", + f"ui.origbackuppath={backup}", + "--config", + "merge.checkignored=abort", + "--config", + "ui.interactive=false", + "unshelve", + "--keep", + "--name", + self._shelf_name(token), + "--tool", + "internal:merge", + ], + check_returncode=True, + ) + + def _conflicts(self) -> tuple[SyncConflict, ...]: + entries = json.loads(self._read_hg(["resolve", "--list", "-Tjson"])) + return tuple( + SyncConflict(entry["path"], "text") + for entry in entries + if entry["mergestatus"] == "U" + ) + + def _restore_missing( + self, original: preservation.Record, target: str + ) -> tuple[SyncConflict, ...]: + missing = [name for name, status in original["status"].items() if status == "!"] + if not missing: + return () + before = self._manifest(original["revision"]) + after = self._manifest(target) + conflicts = [] + for name in missing: + path = self._work_path(name) + if before.get(name) != after.get(name): + conflicts.append(SyncConflict(name, "missing-intent-upstream-changed")) + elif name in original.get("missing_added", {}): + if path.exists() or path.is_symlink(): + conflicts.append(SyncConflict(name, "untracked-obstruction")) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.touch(exist_ok=False) + try: + source = original["missing_added"][name] + args = ( + ["copy", "--after", "--", source, name] + if source + else ["add", "--", name] + ) + self.cmd.run(args, check_returncode=True) + finally: + path.unlink() + elif path.is_file() or path.is_symlink(): + path.unlink() + elif path.exists(): + conflicts.append(SyncConflict(name, "tree")) + return tuple(conflicts) + + @staticmethod + def _finish( + store: preservation.RecoveryStore, + token: RecoveryToken, + record: preservation.Record, + result: SyncResult, + ) -> None: + try: + store.finish(token, record, result) + except (OSError, ValueError, TypeError) as error: + result.add_error("publication", str(error), error) + + def list_recoveries(self) -> tuple[SyncResult, ...]: + """List retained and interrupted shelves without replaying their operations.""" + store = self._store() + with store.lock(): + results = store.discover() + for result in results: + assert result.recovery is not None + if any(error.step == "recovery-record" for error in result.errors): + continue + try: + self._material( + store, result.recovery, self._record(store, result.recovery) + ) + except ( + OSError, + ValueError, + TypeError, + KeyError, + exc.LibVCSException, + ) as error: + result.add_error("recovery-material", str(error), error) + return results + + def update_repo( + self, + *args: t.Any, + target: SyncTarget | None = None, + policy: SyncPolicy | None = None, + **kwargs: t.Any, + ) -> SyncResult: + """Pull without updating, then apply explicit target and dirty policy. + + Preservation retains a checksummed native shelf and recovery token after + success or failure. Callers must exclude external writers and editors. """ result = SyncResult() - if not pathlib.Path(self.path / ".hg").exists(): - try: - self.obtain() - except exc.CommandError as e: - self.log.exception("Failed to obtain repository") - result.add_error("obtain", str(e), exception=e) - return result - return self.update_repo() - else: - try: - self.cmd.update() - self.cmd.pull(update=True) - except exc.CommandError as e: - result.add_error("pull", str(e), exception=e) + policy = policy or SyncPolicy() + step = "precondition" + try: + if target is None and self.rev is not None: + target = SyncTarget(rev=self.rev) + created = not (self.path / ".hg").exists() + if created: + step = "obtain" + self._obtain(target) + store = self._store() + with store.lock(): + for retained in store.discover(): + assert retained.recovery is not None + if any( + error.step == "recovery-record" for error in retained.errors + ): + return retained + try: + retained_record = self._record(store, retained.recovery) + except (OSError, ValueError, TypeError, KeyError) as error: + retained.add_error("recovery-record", str(error), error) + return retained + if retained_record["phase"] not in preservation.TERMINAL: + return retained + status = self._precondition() + original = self.get_position() + branch = self._read_hg(["branch"]).strip() + pending_branch = branch != self._read_hg( + ["log", "-r", ".", "-T", "{branch}"] + ) + dirty = bool(status) or pending_branch + step = "target" + if not created and policy.drift != "follow": + resolved = self.resolve_target(target) + if ( + original.revision != resolved.revision + and policy.drift == "warn" + ): + logger.warning( + "configured Mercurial target drifted", + extra={ + "vcs_event": "target_drift", + "vcs_type": "hg", + "vcs_repo_path": str(self.path), + }, + ) + return result + if dirty and policy.dirty == "abort": + result.add_error( + "dirty", "Mercurial working copy has local changes" + ) + return result + step = "set-remotes" + source = target.remote if target is not None else None + if ( + source is not None + and source not in self._remotes + and source not in self.remotes() + ): + msg = f"Mercurial remote is unavailable: {source}" + raise ValueError(msg) # noqa: TRY301 - return an unstarted result + self._set_remotes(overwrite=True) + step = "pull" + self.cmd.pull( + source=source, + update=False, + ssh=self.options.ssh, + remote_cmd=self.options.remote_cmd, + insecure=not self.options.tls_verify, + check_returncode=True, + ) + step = "target" + resolved = self.resolve_target(target) + attachment_changed = resolved.follows and ( + original.ref_kind != resolved.ref_kind + or original.ref_name != resolved.ref_name + ) + if original.revision == resolved.revision and not attachment_changed: + return result + self._ignored_collisions( + original.revision, resolved.revision, dirty=dirty + ) + token: RecoveryToken | None = None + record: preservation.Record | None = None + if dirty and policy.dirty == "preserve": + step = "capture" + manifest = self._manifest(original.revision) + schedules = json.loads( + self._read_hg(["status", "--copies", "-Tjson"]) + ) + missing_added = { + item["path"]: item.get("source", "") + for item in schedules + if item["status"] == "!" and item["path"] not in manifest + } + recreated = { + name: str(index) + for index, (name, state) in enumerate(status.items()) + if state == "R" + and ( + self._work_path(name).exists() + or self._work_path(name).is_symlink() + ) + } + original_data = dataclasses.asdict(original) + original_data.update( + status=status, + missing_added=missing_added, + recreated=recreated, + branch=branch, + pending_branch=pending_branch, + default=self.url, + ) + token, record = store.create( + original=original_data, target=dataclasses.asdict(resolved) + ) + result.recovery = token + result.preservation_state = "unknown" + try: + backup = store.token_path(token) / "backups" + self._capture_recreated(store, token, record) + if self._needs_shelf(record): + self.cmd.run( + [ + "--config", + f"ui.origbackuppath={backup}", + "--config", + "merge.checkignored=abort", + "shelve", + "--unknown", + "--name", + self._shelf_name(token), + "--message", + record["marker"], + *[ + argument + for name in recreated + for argument in ("--exclude", "path:" + name) + ], + ], + check_returncode=True, + ) + self._seal(store, token, record) + result.preservation_state = "saved" + except ( + OSError, + ValueError, + TypeError, + KeyError, + exc.LibVCSException, + ) as error: + result.add_error("capture", str(error), error) + try: + if ( + "inventory" not in record["native"] + and not (store.token_path(token) / "material").exists() + ): + self._seal(store, token, record) + self._material(store, token, record) + result.preservation_state = "saved" + except ( + OSError, + ValueError, + TypeError, + KeyError, + exc.LibVCSException, + ) as inspection: + result.add_error( + "capture-inspection", str(inspection), inspection + ) + self._finish(store, token, record, result) + return result + step = "update" + if token is not None and record is not None: + store.phase(token, record, "updating") + try: + if token is not None and record is not None: + missing = [ + name + for name, state in status.items() + if state == "!" or name in record["original"]["recreated"] + ] + if missing: + self.cmd.run( + [ + "revert", + "--no-backup", + "--rev", + original.revision, + "--", + *missing, + ], + check_returncode=True, + ) + if dirty and policy.dirty == "discard": + self.cmd.run( + [ + "revert", + "--all", + "--no-backup", + "--rev", + original.revision, + ], + check_returncode=True, + ) + for name, state in status.items(): + if state in {"?", "A"}: + path = self._work_path(name) + if path.is_file() or path.is_symlink(): + path.unlink() + result.update_state = "unknown" + self.cmd.run( + [ + "--config", + "merge.checkignored=abort", + "--config", + "ui.interactive=false", + "update", + "--rev", + resolved.revision, + ], + check_returncode=True, + ) + result.update_state = "completed" + if resolved.ref_kind == "bookmark": + self.cmd.run( + ["bookmark", "--force", "--", resolved.ref_name], + check_returncode=True, + ) + except ( + OSError, + ValueError, + TypeError, + KeyError, + exc.LibVCSException, + ) as error: + if result.update_state == "unknown": + result.update_state = "failed" + result.add_error("update", str(error), error) + if token is not None and record is not None: + try: + store.phase(token, record, "inspecting") + if self._needs_shelf(record): + self._unshelve( + token, backup=store.token_path(token) / "backups" + ) + result.conflicts = self._restore_recreated(store, token, record) + if record["original"]["pending_branch"]: + self.cmd.run( + [ + "branch", + "--force", + "--", + record["original"]["branch"], + ], + check_returncode=True, + ) + result.preservation_state = "restored" + except ( + OSError, + ValueError, + TypeError, + KeyError, + exc.LibVCSException, + ) as error: + result.preservation_state = "failed" + result.add_error("restore", str(error), error) + try: + result.conflicts += self._conflicts() + if ( + result.update_state == "completed" + and not result.conflicts + and result.preservation_state == "restored" + ): + result.conflicts += self._restore_missing( + record["original"], resolved.revision + ) + if result.update_state != "completed" and any( + state == "!" and self._work_path(name).exists() + for name, state in record["original"]["status"].items() + ): + result.preservation_state = "failed" + result.add_error( + "restore", + "missing paths need separate recovery", + ) + if result.conflicts: + result.preservation_state = "conflicted" + result.add_error( + "conflicts", + "Mercurial restoration has unresolved conflicts", + ) + elif (self.path / ".hg" / "shelvedstate").exists(): + result.preservation_state = "unknown" + result.add_error( + "inspection", "Mercurial unshelve remains incomplete" + ) + except ( + OSError, + ValueError, + TypeError, + KeyError, + exc.LibVCSException, + ) as error: + result.preservation_state = "unknown" + result.add_error("inspection", str(error), error) + self._finish(store, token, record, result) + except ( + OSError, + ValueError, + TypeError, + KeyError, + RuntimeError, + exc.LibVCSException, + ) as error: + result.add_error(step, str(error), error) + return result + + def recover_changes( + self, token: RecoveryToken, *, destination: StrPath + ) -> SyncResult: + """Restore original native state independently using retained local history.""" + result = SyncResult(recovery=token, preservation_state="unknown") + try: + store = self._store() + with store.lock(): + record = self._record(store, token) + store.validate_source(record) + material = self._material(store, token, record) + dest = store.destination(destination) + original = record["original"] + self._node(original["revision"]) + recovered = HgSync(url=str(store.source), path=dest) + recovered.cmd.clone( + url=str(store.source), + pull=True, + no_update=True, + rev=original["revision"], + check_returncode=True, + ) + recovered.cmd.run( + ["update", "--rev", original["revision"]], check_returncode=True + ) + if self._needs_shelf(record): + assert material is not None + shelf_dir = dest / ".hg" / "shelved" + shelf_dir.mkdir(mode=0o700, exist_ok=True) + for suffix in (".hg", ".patch", ".shelve"): + path = preservation.safe_path( + material / (self._shelf_name(token) + suffix) + ) + if path.exists(): + shutil.copy2(path, shelf_dir / path.name) + recovered._unshelve(token, backup=dest / ".hg" / "libvcs-backups") + conflicts = recovered._restore_recreated(store, token, record) + conflicts += recovered._restore_missing(original, original["revision"]) + if ( + conflicts + or recovered._conflicts() + or recovered._status() != original["status"] + ): + msg = ( + "Mercurial recovery did not restore the original native status" + ) + raise ValueError(msg) # noqa: TRY301 - return recovery failure with token + recovered.cmd.run( + ["branch", "--force", "--", original["branch"]], + check_returncode=True, + ) + if original["ref_kind"] == "bookmark": + recovered.cmd.run( + ["bookmark", "--force", "--", original["ref_name"]], + check_returncode=True, + ) + configuration = configparser.ConfigParser(interpolation=None) + configuration["paths"] = {"default": original["default"]} + with (dest / ".hg" / "hgrc").open("w") as stream: + configuration.write(stream) + result.preservation_state = "restored" + except ( + OSError, + ValueError, + TypeError, + KeyError, + RuntimeError, + exc.LibVCSException, + ) as error: + result.preservation_state = "failed" + result.add_error("recovery", str(error), error) return result + + def release_changes(self, token: RecoveryToken) -> None: + """Release verified owned shelf artifacts while preserving unrelated shelves.""" + store = self._store() + with store.lock(): + record = self._record(store, token) + store.validate_source(record) + material = self._material(store, token, record) + if self._needs_shelf(record): + assert material is not None + name = self._shelf_name(token) + paths = [] + for suffix in (".hg", ".patch", ".shelve"): + source = preservation.safe_path( + self.path / ".hg" / "shelved" / (name + suffix) + ) + if not source.exists(): + continue + if source.read_bytes() != (material / source.name).read_bytes(): + msg = "native Mercurial shelf ownership changed" + raise ValueError(msg) + paths.append(source) + for path in paths: + path.unlink() + store.remove(token) diff --git a/src/libvcs/sync/svn.py b/src/libvcs/sync/svn.py index f4968ea07..1c1a2b366 100644 --- a/src/libvcs/sync/svn.py +++ b/src/libvcs/sync/svn.py @@ -13,21 +13,63 @@ from __future__ import annotations +import dataclasses import logging import os import pathlib import re +import sqlite3 +import subprocess import typing as t +import xml.etree.ElementTree as et from libvcs import exc +from libvcs._internal import preservation, svn_preservation +from libvcs._internal.run import ProgressCallbackProtocol from libvcs._internal.types import StrPath -from libvcs.cmd.svn import Svn +from libvcs.cmd.svn import DepthLiteral, Svn -from .base import BaseSync, SyncResult +from .base import ( + BaseSync, + RecoveryToken, + SyncPolicy, + SyncResult, + SyncTarget, + WorkingCopyPosition, +) logger = logging.getLogger(__name__) +@dataclasses.dataclass(frozen=True) +class SvnOptions: + """Backend-specific options for Subversion synchronization.""" + + username: str | None = None + password: str | None = dataclasses.field(default=None, repr=False) + depth: DepthLiteral = None + trust_server_cert: bool = False + ignore_externals: bool = False + + def __post_init__(self) -> None: + """Validate Subversion option types without running Subversion.""" + for name in ("username", "password"): + value = getattr(self, name) + if value is not None and not isinstance(value, str): + msg = f"{name} must be a string or None" + raise TypeError(msg) + if value is not None and "\0" in value: + msg = f"{name} must not contain NUL" + raise ValueError(msg) + if self.depth not in (None, "empty", "files", "immediates", "infinity"): + msg = "depth must be empty, files, immediates, infinity, or None" + raise ValueError(msg) + for name in ("trust_server_cert", "ignore_externals"): + if not isinstance(getattr(self, name), bool): + msg = f"{name} must be a boolean" + raise TypeError(msg) + + class SvnUrlRevFormattingError(ValueError): """Raised when SVN Revision output is not in the expected format.""" @@ -41,13 +83,16 @@ class SvnSync(BaseSync): bin_name = "svn" schemes = ("svn", "svn+ssh", "svn+http", "svn+https", "svn+svn") cmd: Svn + options_type = SvnOptions def __init__( self, *, url: str, path: StrPath, - **kwargs: t.Any, + options: SvnOptions | None = None, + progress_callback: ProgressCallbackProtocol | None = None, + rev: str | None = None, ) -> None: """Working copy of a SVN repository. @@ -56,23 +101,21 @@ def __init__( url : str URL in subversion repository - username : str, optional - username to use for checkout and update - - password : str, optional - password to use for checkout and update - - svn_trust_cert : bool - trust the Subversion server site certificate, default False + options : SvnOptions, optional + Subversion-specific checkout and transport configuration. """ - self.svn_trust_cert = kwargs.pop("svn_trust_cert", False) - - self.username = kwargs.get("username") - self.password = kwargs.get("password") - - self.rev = kwargs.get("rev") - - super().__init__(url=url, path=path, **kwargs) + if options is None: + options = SvnOptions() + elif not isinstance(options, SvnOptions): + msg = "options must be an SvnOptions instance" + raise TypeError(msg) + self.options = options + super().__init__( + url=url, + path=path, + progress_callback=progress_callback, + rev=rev, + ) self.cmd = Svn(path=path, progress_callback=self.progress_callback) @@ -85,22 +128,108 @@ def _user_pw_args(self) -> list[t.Any]: def obtain(self, quiet: bool | None = None, *args: t.Any, **kwargs: t.Any) -> None: """Check out a working copy from a SVN repository.""" - url, rev = self.url, self.rev + url, rev = self.url, kwargs.pop("revision", self.rev) - if rev is not None: - kwargs["revision"] = rev - if self.svn_trust_cert: - kwargs["trust_server_cert"] = True self.cmd.checkout( url=url, - username=self.username, - password=self.password, + revision=rev, + username=self.options.username, + password=self.options.password, + depth=self.options.depth, + trust_server_cert=self.options.trust_server_cert, + ignore_externals=self.options.ignore_externals, non_interactive=True, quiet=True, check_returncode=True, **kwargs, ) + def get_position(self) -> WorkingCopyPosition: + """Read base revisions and switched subtrees without contacting the server.""" + wc = svn_preservation.WorkingCopy(self.path) + info = wc.xml(["info", "--xml", "--", "."]) + root = info.find("entry") + if root is None or root.findtext("url") is None: + message = "missing working-copy entry" + raise SvnUrlRevFormattingError(message) + status = wc.xml( + ["status", "--verbose", "--xml", "--ignore-externals", "--", "."] + ) + revisions = { + item.attrib["revision"] + for item in status.iter("wc-status") + if item.attrib.get("revision", "").isdecimal() + } + return WorkingCopyPosition( + revision=root.attrib["revision"], + ref_name=t.cast("str", root.findtext("url")), + ref_kind="url", + follows=self.rev in (None, "HEAD"), + mixed=len(revisions) > 1, + switched=any( + item.get("switched") == "true" for item in status.iter("wc-status") + ), + ) + + def _selection(self, target: SyncTarget | None) -> str: + if target is None: + target = SyncTarget(rev=self.rev or "HEAD") + if target.rev is None or target.remote is not None: + msg = "SVN targets require rev; other selectors and remote are unsupported" + raise ValueError(msg) + revision = str(target.rev) + if revision != "HEAD" and not revision.isdecimal(): + msg = "SVN target revision must be a nonnegative number or HEAD" + raise ValueError(msg) + return revision + + def resolve_target(self, target: SyncTarget | None = None) -> WorkingCopyPosition: + """Read explicit revision and URL facts without querying remote HEAD.""" + revision = self._selection(target) + if revision == "HEAD": + msg = "SVN remote HEAD is unavailable from local working-copy metadata" + raise ValueError(msg) + return WorkingCopyPosition( + str(int(revision)), self.url.rstrip("/"), "url", follows=False + ) + + def is_dirty(self) -> bool: + """Include schedules, property edits, missing paths, and unknown files.""" + return svn_preservation.dirty(svn_preservation.WorkingCopy(self.path).native()) + + def _store(self) -> preservation.RecoveryStore: + return preservation.RecoveryStore( + self.path, "svn", self.path / ".svn", lock_in_store=True + ) + + def _remote_target( + self, wc: svn_preservation.WorkingCopy, revision: str + ) -> WorkingCopyPosition: + args = ["info", "--xml", "--revision", revision] + for name in ("username", "password"): + value = getattr(self.options, name) + if value is not None: + args.extend(["--" + name, value]) + if self.options.trust_server_cert: + args.append("--trust-server-cert") + remote = wc.xml([*args, "--", self.url]) + local = wc.xml(["info", "--xml", "--", "."]) + if remote.findtext("entry/repository/uuid") != local.findtext( + "entry/repository/uuid" + ): + msg = "SVN target belongs to a different repository" + raise ValueError(msg) + entry = remote.find("entry") + if entry is None or entry.get("kind") != "dir" or entry.findtext("url") is None: + msg = "SVN target is not an available directory" + raise ValueError(msg) + return WorkingCopyPosition( + entry.attrib["revision"], + t.cast("str", entry.findtext("url")), + "url", + follows=revision == "HEAD", + ) + def get_revision_file(self, location: str) -> int: """Return revision for a file.""" current_rev = self.cmd.info(location) @@ -146,45 +275,270 @@ def update_repo( self, dest: str | None = None, *args: t.Any, + target: SyncTarget | None = None, + policy: SyncPolicy | None = None, **kwargs: t.Any, ) -> SyncResult: - """Fetch changes from SVN repository to local working copy. - - Parameters - ---------- - dest : str or None, optional - Destination path override for the working copy. + """Update or switch natively, retaining a full copy under explicit preservation. - Returns - ------- - SyncResult - Result of the sync operation, with any errors recorded. + POSIX format-31 working copies are supported. Callers must exclude editors + and other VCS writers. Sealed copies include administrative/pristine storage. """ result = SyncResult() - self.ensure_dir() - if pathlib.Path(self.path / ".svn").exists(): - try: - self.cmd.checkout( - url=self.url, - username=self.username, - password=self.password, - non_interactive=True, - quiet=True, - check_returncode=True, - **kwargs, + policy = policy or SyncPolicy() + step = "target" + try: + revision = self._selection(target) + if not (self.path / ".svn").exists(): + step = "precondition" + if any( + (parent / ".svn").exists() + for parent in self.path.absolute().parents + ): + msg = "SVN synchronization requires the true working-copy root" + raise ValueError(msg) # noqa: TRY301 - return precondition failure + step = "obtain" + self.obtain(revision=revision) + wc = svn_preservation.WorkingCopy(self.path, timeout=kwargs.get("timeout")) + store = self._store() + step = "precondition" + with store.lock(): + for retained in store.discover(): + assert retained.recovery is not None + if any( + error.step == "recovery-record" for error in retained.errors + ): + return retained + retained_record = store.read(retained.recovery) + if retained_record["phase"] not in preservation.TERMINAL: + return retained + try: + svn_preservation.validate_record(retained_record) + except (ValueError, TypeError, KeyError, et.ParseError) as error: + retained.add_error("recovery-record", str(error), error) + return retained + native = wc.precondition() + original = self.get_position() + step = "target" + if policy.drift != "follow": + resolved = self.resolve_target(target) + if policy.drift == "warn" and ( + resolved.revision != original.revision + or resolved.ref_name != original.ref_name + ): + logger.warning( + "configured SVN target drifted", + extra={ + "vcs_event": "target_drift", + "vcs_type": "svn", + "vcs_repo_path": str(self.path), + }, + ) + return result + dirty = svn_preservation.dirty(native) + if dirty and policy.dirty == "abort": + result.add_error("dirty", "SVN working copy has local changes") + return result + resolved = self._remote_target(wc, revision) + token: RecoveryToken | None = None + record = None + if dirty and policy.dirty == "preserve": + step = "capture" + token, record = store.create( + original=dataclasses.asdict(original), + target=dataclasses.asdict(resolved), + ) + result.recovery = token + result.preservation_state = "unknown" + store.validate_source(record) + svn_preservation.capture(wc, store, token, record) + store.validate_source(record) + result.preservation_state = "saved" + step = "update" + if token is not None and record is not None: + store.phase(token, record, "updating") + try: + if dirty and policy.dirty == "discard": + self._discard(wc, native, **kwargs) + command = ( + "update" if original.ref_name == resolved.ref_name else "switch" + ) + flags = [ + command, + "--accept", + "postpone", + "--ignore-externals", + "--revision", + resolved.revision, + "--", + ] + if command == "switch": + flags.append(resolved.ref_name) + flags.append(".") + result.update_state = "unknown" + self.cmd.run( + flags, + username=self.options.username, + password=self.options.password, + trust_server_cert=self.options.trust_server_cert, + non_interactive=True, + check_returncode=True, + **kwargs, + ) + result.update_state = "completed" + except (OSError, ValueError, exc.LibVCSException) as error: + if result.update_state == "unknown": + result.update_state = "failed" + result.add_error("update", str(error), error) + try: + if token is not None and record is not None: + store.phase(token, record, "inspecting") + current = wc.native() + result.conflicts = svn_preservation.conflicts(current) + if ( + token is not None + and record is not None + and result.update_state == "completed" + ): + result.conflicts += svn_preservation.restore_missing( + wc, record["native"]["metadata"] + ) + result.preservation_state = "restored" + if result.conflicts: + result.preservation_state = "conflicted" + result.add_error( + "conflicts", "SVN update has unresolved conflicts" + ) + except ( + OSError, + ValueError, + TypeError, + KeyError, + et.ParseError, + subprocess.SubprocessError, + exc.LibVCSException, + ) as error: + result.preservation_state = "unknown" + result.add_error("inspection", str(error), error) + if token is not None and record is not None: + try: + store.finish(token, record, result) + except (OSError, ValueError, TypeError) as error: + result.add_error("publication", str(error), error) + except ( + OSError, + ValueError, + TypeError, + KeyError, + RuntimeError, + sqlite3.Error, + et.ParseError, + subprocess.SubprocessError, + exc.LibVCSException, + ) as error: + result.add_error(step, str(error), error) + return result + + def _discard( + self, + wc: svn_preservation.WorkingCopy, + native: preservation.Record, + **kwargs: t.Any, + ) -> None: + remove = { + name + for name, item in svn_preservation.statuses(native).items() + if item.get("item") in {"added", "unversioned"} + } + physical = svn_preservation.tree(wc.path) + for name in remove: + if set(svn_preservation.scope(physical, name)) - remove: + msg = "SVN discard cannot remove unclassified or ignored descendants" + raise ValueError(msg) + self.cmd.run( + ["revert", "--depth", "infinity", "--", "."], + check_returncode=True, + **kwargs, + ) + for name in sorted( + remove, + key=lambda value: len(pathlib.PurePosixPath(value).parts), + reverse=True, + ): + path = wc.path_for(name) + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + path.rmdir() + + def list_recoveries(self) -> tuple[SyncResult, ...]: + """List retained copies and report incomplete material with tokens.""" + store = self._store() + wc = svn_preservation.WorkingCopy(self.path) + with store.lock(): + results = store.discover(require_repository=False) + for result in results: + assert result.recovery is not None + if any(error.step == "recovery-record" for error in result.errors): + continue + try: + svn_preservation.material( + wc, + store, + result.recovery, + store.read(result.recovery, require_repository=False), + ) + except ( + OSError, + ValueError, + TypeError, + KeyError, + et.ParseError, + exc.LibVCSException, + ) as error: + result.add_error("recovery-material", str(error), error) + return results + + def recover_changes( + self, token: RecoveryToken, *, destination: StrPath + ) -> SyncResult: + """Recover offline after the original checkout is deleted or replaced.""" + result = SyncResult(recovery=token, preservation_state="unknown") + try: + store = self._store() + with store.lock(): + record = store.read(token, require_repository=False) + svn_preservation.recover( + svn_preservation.WorkingCopy(self.path), + store, + token, + record, + store.destination(destination), ) - except exc.CommandError as e: - result.add_error("checkout", str(e), exception=e) - else: - try: - self.obtain() - except exc.CommandError as e: - self.log.exception("Failed to obtain repository") - result.add_error("obtain", str(e), exception=e) - return result - return self.update_repo() + result.preservation_state = "restored" + except ( + OSError, + ValueError, + TypeError, + KeyError, + RuntimeError, + et.ParseError, + exc.LibVCSException, + ) as error: + result.preservation_state = "failed" + result.add_error("recovery", str(error), error) return result + def release_changes(self, token: RecoveryToken) -> None: + """Release only a verified owned full working-copy copy.""" + store = self._store() + with store.lock(): + record = store.read(token, require_repository=False) + svn_preservation.material( + svn_preservation.WorkingCopy(self.path), store, token, record + ) + store.remove(token, require_repository=False) + @classmethod def _get_svn_url_rev(cls, location: str) -> tuple[str | None, int]: svn_xml_url_re = re.compile(r'url="([^"]+)"') diff --git a/tests/_internal/test_preservation.py b/tests/_internal/test_preservation.py new file mode 100644 index 000000000..022f0c890 --- /dev/null +++ b/tests/_internal/test_preservation.py @@ -0,0 +1,182 @@ +"""Durable recovery ownership and publication checks.""" + +from __future__ import annotations + +import dataclasses +import pathlib + +import pytest + +from libvcs import RecoveryToken, SyncPolicy, SyncTarget +from libvcs._internal.preservation import RecoveryStore, atomic_record, inventory + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"branch": "a", "tag": "b"}, + {"rev": True}, + {"rev": -1}, + {"branch": "-bad"}, + {"remote": "origin"}, + ], +) +def test_target_rejects_invalid_selectors(kwargs: dict[str, object]) -> None: + """Invalid selectors fail without invoking a backend.""" + with pytest.raises((ValueError, TypeError)): + SyncTarget(**kwargs) # type: ignore[arg-type] + + +def test_policy_and_target_are_immutable() -> None: + """Policy defaults protect dirt and malformed policy names fail early.""" + assert SyncPolicy().dirty == "abort" + assert SyncTarget(rev=0).rev == 0 + with pytest.raises(ValueError, match="dirty"): + SyncPolicy(dirty="reset") # type: ignore[arg-type] + with pytest.raises(dataclasses.FrozenInstanceError): + SyncTarget(branch="main").branch = "next" # type: ignore[misc] + + +def test_store_publication_discovery_and_lock(tmp_path: pathlib.Path) -> None: + """Interrupted records remain discoverable and concurrent owners fail promptly.""" + source = tmp_path / "source" + source.mkdir() + store = RecoveryStore(source, "git", source) + with store.lock(): + token, record = store.create(original={"revision": "base"}, target={}) + with pytest.raises(RuntimeError, match="busy"), store.lock(): + pytest.fail("acquired an owned lock") + snapshots = store.discover() + assert len(snapshots) == 1 + assert snapshots[0].recovery == token + assert snapshots[0].preservation_state == "unknown" + assert not snapshots[0].ok + assert store.read(token)["phase"] == "capturing" + with pytest.raises(ValueError, match="backend"): + store.read(dataclasses.replace(token, backend="hg")) + with pytest.raises(ValueError, match="location"): + store.read(RecoveryToken(token.id, "git", str(tmp_path))) + source.rename(tmp_path / "old-source") + source.mkdir() + # Replaced filesystems may immediately reuse inodes; force a mismatched record. + record["source_identity"] = [-1, -1] + store.write(token, record) + with pytest.raises(ValueError, match="identity"): + store.validate_source(record) + + +def test_atomic_record_keeps_previous_on_publish_failure( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed rename leaves the prior complete record readable.""" + path = tmp_path / "operation.json" + atomic_record(path, {"phase": "sealed"}) + + def fail_replace(*args: object) -> None: + msg = "disk full" + raise OSError(msg) + + monkeypatch.setattr(pathlib.Path, "replace", fail_replace) + with pytest.raises(OSError, match="disk full"): + atomic_record(path, {"phase": "updating"}) + assert '"sealed"' in path.read_text() + assert list(tmp_path.iterdir()) == [path] + + +def test_destination_and_inventory_boundaries(tmp_path: pathlib.Path) -> None: + """Destinations cannot overlap sources or follow dangling symlinks.""" + source = tmp_path / "source" + source.mkdir() + (source / "file").write_bytes(b"\x00payload") + (source / "link").symlink_to("file") + store = RecoveryStore(source, "git", source) + for destination in [source, source / "nested", tmp_path]: + with pytest.raises(ValueError): + store.destination(destination) + dangling = tmp_path / "dangling" + dangling.symlink_to(tmp_path / "absent") + with pytest.raises(ValueError, match=r"symlink|exist"): + store.destination(dangling) + data = inventory(source) + assert data["file"]["sha256"] + assert data["link"]["target"] == "file" + (source / "file").write_bytes(b"changed") + assert inventory(source) != data + + +@pytest.mark.parametrize( + "field,value", + [ + ("phase", []), + ("phase", {}), + ("phase", 1), + ("result", {"update_state": []}), + ("result", {"preservation_state": {}}), + ("result", {"errors": {}}), + ("result", {"errors": [{"step": 1, "message": "bad"}]}), + ("result", {"conflicts": [{"path": [], "reason": "text"}]}), + ], +) +def test_store_rejects_record_schema_damage( + tmp_path: pathlib.Path, + field: str, + value: object, +) -> None: + """Valid JSON with malformed field types fails as a record validation error.""" + source = tmp_path / "source" + source.mkdir() + store = RecoveryStore(source, "git", source) + token, record = store.create(original={}, target={}) + record[field] = value + store.write(token, record) + with pytest.raises(ValueError, match=r"record|result"): + store.read(token) + found = store.discover()[0] + assert not found.ok + assert found.recovery == token + + +def test_store_lock_outlives_source_and_release(tmp_path: pathlib.Path) -> None: + """Opt-in snapshot ownership stays locked independently of native administration.""" + source = tmp_path / "source" + source.mkdir() + store = RecoveryStore(source, "svn", source, lock_in_store=True) + with store.lock(): + token, _ = store.create(original={}, target={}) + identity = store.lock_path.stat().st_ino + source.rmdir() + with pytest.raises(OSError): + store.read(token) + assert store.read(token, require_repository=False)["id"] == token.id + with pytest.raises(RuntimeError, match="busy"), store.lock(): + pytest.fail("acquired an owned snapshot lock") + store.remove(token, require_repository=False) + assert store.lock_path.stat().st_ino == identity + with pytest.raises(RuntimeError, match="busy"), store.lock(): + pytest.fail("release removed the active ownership lock") + + +@pytest.mark.parametrize( + "field,value", + [ + ("source_identity", []), + ("repository_identity", {}), + ("repository_identity", [True, 1]), + ], +) +def test_independent_record_reads_validate_identity_types( + tmp_path: pathlib.Path, field: str, value: object +) -> None: + """Skipping current native identity never skips persisted identity validation.""" + source = tmp_path / "source" + source.mkdir() + store = RecoveryStore(source, "svn", source, lock_in_store=True) + token, record = store.create(original={}, target={}) + record[field] = value + store.write(token, record) + with pytest.raises(ValueError, match="identity"): + store.read(token, require_repository=False) + result = store.discover(require_repository=False)[0] + assert not result.ok + assert result.recovery == token diff --git a/tests/_internal/test_run.py b/tests/_internal/test_run.py index 710e4c9b3..e9aea7672 100644 --- a/tests/_internal/test_run.py +++ b/tests/_internal/test_run.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os import pathlib import subprocess import sys @@ -130,6 +131,57 @@ def test_run_without_timeout_completes_successfully() -> None: assert "ok" in output +@pytest.mark.parametrize("error_type", [RuntimeError, KeyboardInterrupt]) +def test_run_callback_failure_reaps_child( + monkeypatch: pytest.MonkeyPatch, error_type: type[BaseException] +) -> None: + """An exception from streamed output stops the child before propagating.""" + processes: list[subprocess.Popen[bytes]] = [] + original_popen = subprocess.Popen + + def capture(*args: t.Any, **kwargs: t.Any) -> subprocess.Popen[bytes]: + process = original_popen(*args, **kwargs) + processes.append(process) + return process + + # Capture the owned child so the assertion distinguishes cleanup from leakage. + monkeypatch.setattr(subprocess, "Popen", capture) + expected = error_type("callback stopped") + + def fail(output: str, timestamp: t.Any) -> None: + raise expected + + try: + with pytest.raises(error_type) as caught: + run( + [ + sys.executable, + "-c", + ( + "import sys; print('ready', file=sys.stderr, flush=True); " + "sys.stdin.buffer.read()" + ), + ], + stdin=subprocess.PIPE, + callback=fail, + timeout=1, + ) + assert caught.value is expected + assert processes[0].returncode is not None + assert all( + stream is None or stream.closed + for stream in (processes[0].stdin, processes[0].stdout, processes[0].stderr) + ) + finally: + for process in processes: + if process.poll() is None: + process.kill() + process.wait(timeout=1) + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + + def test_run_timeout_none_is_the_default() -> None: """Omitting ``timeout`` is equivalent to ``timeout=None``.""" output = run([sys.executable, "-c", "print('default')"], timeout=None) @@ -176,19 +228,28 @@ def test_run_timeout_message_includes_duration( assert "0.3" in rendered -def test_run_timeout_does_not_deadlock_on_chatty_stdout() -> None: - """A child filling its stdout pipe must not deadlock the deadline loop. - - The OS pipe buffer is typically 64 KiB on Linux. The child below writes - well past that before exiting; if the parent only drained ``stderr``, the - child would block on ``write()`` and only ``terminate()`` would unwedge - it, losing all the legitimate output. - """ - script = "import sys; sys.stdout.write('x' * 200000); sys.stdout.flush()" +@pytest.mark.skipif(os.name != "posix", reason="uses a child alarm to bound deadlocks") +@pytest.mark.parametrize("timeout", [None, 15.0]) +@pytest.mark.parametrize("streaming", [False, True]) +def test_run_drains_both_pipes(timeout: float | None, streaming: bool) -> None: + """Both pipes drain with or without a deadline and progress callback.""" + script = ( + "import os, signal; signal.setitimer(signal.ITIMER_REAL, 0.5); " + "os.write(1, b'x' * 200000); os.write(2, b'y' * 200000)" + ) + progress = [] - output = run([sys.executable, "-c", script], timeout=15.0) + def callback(output: str, timestamp: t.Any) -> None: + progress.append(output) - assert len(output) >= 200000 + output = run( + [sys.executable, "-c", script], + timeout=timeout, + callback=callback if streaming else None, + ) + assert output == "x" * 200000 + if streaming: + assert "".join(progress).rstrip("\r") == "y" * 200000 def test_run_timeout_preserves_stdout_after_exit() -> None: diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 2851ce4b9..6ff7af8a5 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -21,6 +21,23 @@ from libvcs.sync.git import GitSync +def test_remote_listing_preserves_filtered_fetch_url( + git_repo: GitSync, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A partial-clone annotation must not hide a remote's fetch URL.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + git_repo.cmd.run( + ["config", "--local", "remote.origin.partialclonefilter", "blob:none"], + check_returncode=True, + ) + remote = git_repo.cmd.remotes.get(remote_name="origin") + + assert remote is not None + assert remote.fetch_url == git_repo.url + assert remote.push_url == git_repo.url + + @pytest.mark.parametrize("path_type", [str, pathlib.Path]) def test_git_constructor( path_type: t.Callable[[str | pathlib.Path], t.Any], @@ -49,6 +66,32 @@ def test_git_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None: assert result.startswith("git version ") +def test_git_run_places_global_configuration_before_subcommand( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Command-scoped configuration reaches Git and stays out of config files.""" + repo = git.Git(path=tmp_path) + monkeypatch.setenv("LIBVCS_TEST_CONFIG_VALUE", "from-environment") + + assert ( + repo.run( + ["config", "--get", "http.sslVerify"], + config={"http.sslVerify": False}, + ).strip() + == "false" + ) + assert ( + repo.run( + ["config", "--get", "libvcs.test"], + config_env="libvcs.test=LIBVCS_TEST_CONFIG_VALUE", + ).strip() + == "from-environment" + ) + with pytest.raises(exc.CommandError): + repo.run(["config", "--get", "libvcs.test"]) + + def test_git_run_timeout_propagates_to_runner( tmp_path: pathlib.Path, mocker: MockerFixture, @@ -1992,8 +2035,9 @@ def test_notes_get_ref(git_repo: GitSync) -> None: assert result == "refs/notes/commits" or result == "" or "notes" in result -def test_notes_edit(git_repo: GitSync, tmp_path: pathlib.Path) -> None: - """Test GitNoteCmd.edit() - non-interactive mode via GIT_EDITOR.""" +def test_notes_edit(git_repo: GitSync, monkeypatch: pytest.MonkeyPatch) -> None: + """Command-scoped editor configuration runs without changing note content.""" + monkeypatch.delenv("GIT_EDITOR", raising=False) # Add a note first git_repo.cmd.notes.add(message="Initial note for edit test", force=True) @@ -2002,12 +2046,10 @@ def test_notes_edit(git_repo: GitSync, tmp_path: pathlib.Path) -> None: note = git_repo.cmd.notes.get(object_sha=head_sha) assert note is not None - # Edit with allow_empty (avoid interactive editor by using config) - # The doctest uses config={'core.editor': 'true'} which sets a no-op editor result = note.edit(allow_empty=True, config={"core.editor": "true"}) - # Should succeed (empty string) or show error about editor - assert result == "" or "error" in result.lower() or isinstance(result, str) + assert result == "" + assert note.show().strip() == "Initial note for edit test" def test_notes_copy(git_repo: GitSync) -> None: @@ -2836,3 +2878,119 @@ def test_clone_places_end_of_options_before_url( assert argv[argv.index("--") + 1] == "https://example.com/repo.git", ( "URL must follow the -- separator, not precede it" ) + + +def test_git_commands_emit_one_flag_per_filter( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Clone and fetch repeat filters; submodule combines them into one flag.""" + repo = git.Git(path=tmp_path) + mock_run = mocker.patch.object(repo, "run", return_value="") + filters = ["blob:none", {"kind": "tree", "depth": 2}] + + repo.clone( + url="https://example.com/repo.git", + _filter=filters, + make_parents=False, + ) + repo.fetch(_filter=filters) + repo.submodule.update(_filter=filters) + + clone_argv, fetch_argv, submodule_argv = ( + [os.fspath(arg) for arg in call.args[0]] for call in mock_run.call_args_list + ) + for argv in (clone_argv, fetch_argv): + assert [arg for arg in argv if arg.startswith("--filter=")] == [ + "--filter=blob:none", + "--filter=tree:2", + ] + assert [arg for arg in submodule_argv if arg.startswith("--filter=")] == [ + "--filter=combine:blob:none+tree:2" + ] + + +def test_submodule_combines_maximum_depth_filters(git_repo: GitSync) -> None: + """Combining repeated flags cannot reject already validated filter nesting.""" + spec = "combine:" * 32 + "blob:none" + result = git_repo.cmd.submodule.update( + init=True, + _filter=[spec, "tree:2"], + check_returncode=True, + ) + + assert result == "" + + +def test_git_commands_accept_legacy_filter_string( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Existing string filter calls keep their argv.""" + repo = git.Git(path=tmp_path) + mock_run = mocker.patch.object(repo, "run", return_value="") + + repo.fetch(_filter="blob:none") + + argv = [os.fspath(arg) for arg in mock_run.call_args.args[0]] + assert "--filter=blob:none" in argv + + +def test_git_pull_rejects_filter_before_process( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Pull rejects every filter because native Git has no filter option.""" + repo = git.Git(path=tmp_path) + mock_run = mocker.patch.object(repo, "run", return_value="") + + with pytest.raises(ValueError, match="pull"): + repo.pull(_filter="blob:none") + + mock_run.assert_not_called() + + +def test_git_submodule_rejects_auto_before_process( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Submodule update rejects Git's clone/fetch-only auto mode.""" + from libvcs.cmd.git_filter import Auto + + repo = git.Git(path=tmp_path) + mock_run = mocker.patch.object(repo, "run", return_value="") + + with pytest.raises(ValueError, match="auto"): + repo.submodule.update(_filter=Auto()) + + mock_run.assert_not_called() + + +def test_git_submodule_update_forwards_run_configuration( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Submodule updates forward Git network configuration to the runner.""" + repo = git.Git(path=tmp_path) + mock_run = mocker.patch.object(repo.submodule, "run", return_value="") + config = {"http.sslVerify": False} + + repo.submodule.update(config=config) + + assert mock_run.call_args.kwargs["config"] == config + + +def test_clone_rejects_filter_before_creating_destination( + tmp_path: pathlib.Path, +) -> None: + """Invalid filter config does not create the clone destination.""" + destination = tmp_path / "checkout" + repo = git.Git(path=destination) + + with pytest.raises(ValueError, match="limit"): + repo.clone( + url="https://example.com/repo.git", + _filter={"kind": "blob:limit", "limit": -1}, + ) + + assert not destination.exists() diff --git a/tests/cmd/test_git_filter.py b/tests/cmd/test_git_filter.py new file mode 100644 index 000000000..daaf3a068 --- /dev/null +++ b/tests/cmd/test_git_filter.py @@ -0,0 +1,269 @@ +"""Tests for typed Git partial-clone filters.""" + +from __future__ import annotations + +import dataclasses +import typing as t + +import pytest + +from libvcs import exc +from libvcs.cmd.git_filter import ( + Auto, + BlobLimit, + BlobNone, + Combine, + GitFilter, + ObjectType, + SparseOid, + TreeDepth, + coerce_filter, + filter_specs, + from_mapping, + parse_filter, +) + +if t.TYPE_CHECKING: + from libvcs.sync.git import GitSync + + +class FilterFixture(t.NamedTuple): + """Canonical filter model and spec pair.""" + + test_id: str + model: GitFilter + spec: str + + +FILTER_FIXTURES = [ + FilterFixture("blob-none", BlobNone(), "blob:none"), + FilterFixture("blob-limit-int", BlobLimit(1024), "blob:limit=1024"), + FilterFixture("blob-limit-octal-string", BlobLimit("001024"), "blob:limit=001024"), + FilterFixture("blob-limit-unit", BlobLimit("4m"), "blob:limit=4m"), + FilterFixture("tree-depth", TreeDepth(2), "tree:2"), + FilterFixture("object-type", ObjectType("commit"), "object:type=commit"), + FilterFixture( + "sparse-oid", SparseOid("refs/filters/base"), "sparse:oid=refs/filters/base" + ), + FilterFixture("auto", Auto(), "auto"), + FilterFixture( + "combine", + Combine((BlobNone(), TreeDepth(1))), + "combine:blob:none+tree:1", + ), + FilterFixture( + "combine-escaped", + Combine((SparseOid("refs/filters/a b+c"),)), + "combine:sparse:oid=refs/filters/a%20b%2Bc", + ), +] + + +@pytest.mark.parametrize( + ("model", "spec"), + [(fixture.model, fixture.spec) for fixture in FILTER_FIXTURES], + ids=[fixture.test_id for fixture in FILTER_FIXTURES], +) +def test_filter_canonical_round_trip( + model: GitFilter, + spec: str, +) -> None: + """Each model serializes to one canonical spec and parses back.""" + assert filter_specs(model) == (spec,) + assert parse_filter(spec) == model + assert coerce_filter(spec) == model + + +@pytest.mark.parametrize( + ("native_spec", "serialized_spec", "equivalent_spec"), + [ + ("blob:limit=010", "blob:limit=010", "blob:limit=8"), + ("blob:limit=+10", "blob:limit=+10", "blob:limit=10"), + ("blob:limit=0x10", "blob:limit=0x10", "blob:limit=16"), + ("blob:limit=1K", "blob:limit=1K", "blob:limit=1024"), + ("tree:010", "tree:8", "tree:8"), + ("tree:+10", "tree:10", "tree:10"), + ("tree:0x10", "tree:16", "tree:16"), + ("tree:1K", "tree:1024", "tree:1024"), + ], +) +def test_filter_parser_preserves_native_git_ulong_semantics( + native_spec: str, + serialized_spec: str, + equivalent_spec: str, + git_repo: GitSync, +) -> None: + """Accepted alternate integers select the same real Git objects.""" + parsed_spec = filter_specs(parse_filter(native_spec)) + + assert parsed_spec == (serialized_spec,) + assert git_repo.cmd.run( + ["rev-list", "--objects", f"--filter={native_spec}", "HEAD"] + ) == git_repo.cmd.run( + ["rev-list", "--objects", f"--filter={equivalent_spec}", "HEAD"] + ) + + +@pytest.mark.parametrize("spec", ["blob:limit=08", "tree:08"]) +def test_filter_parser_rejects_values_rejected_by_git( + spec: str, + git_repo: GitSync, +) -> None: + """Invalid octal values fail in libvcs and real Git.""" + with pytest.raises(ValueError): + parse_filter(spec) + with pytest.raises(exc.CommandError): + git_repo.cmd.run( + ["rev-list", "--objects", f"--filter={spec}", "HEAD"], + check_returncode=True, + ) + + +@pytest.mark.parametrize( + ("mapping", "expected"), + [ + ({"kind": "blob:none"}, BlobNone()), + ({"kind": "blob:limit", "limit": "5k"}, BlobLimit("5k")), + ({"kind": "tree", "depth": 3}, TreeDepth(3)), + ({"kind": "object:type", "type": "tag"}, ObjectType("tag")), + ( + {"kind": "sparse:oid", "oid": "refs/filters/base"}, + SparseOid("refs/filters/base"), + ), + ({"kind": "auto"}, Auto()), + ( + { + "kind": "combine", + "filters": [ + {"kind": "blob:none"}, + {"kind": "tree", "depth": 2}, + ], + }, + Combine((BlobNone(), TreeDepth(2))), + ), + ], +) +def test_filter_from_mapping( + mapping: dict[str, object], + expected: GitFilter, +) -> None: + """Kind-tagged mappings produce typed filters.""" + assert from_mapping(mapping) == expected + assert coerce_filter(mapping) == expected + + +def test_filter_sequence_is_a_combine_model_but_preserves_command_flags() -> None: + """Coercion combines a sequence while command serialization keeps its items.""" + value = ["blob:none", {"kind": "tree", "depth": 2}] + + assert coerce_filter(value) == Combine((BlobNone(), TreeDepth(2))) + assert filter_specs(value) == ("blob:none", "tree:2") + + +def test_filter_models_are_immutable() -> None: + """Filter values cannot change after validation.""" + value = BlobLimit(10) + + with pytest.raises(dataclasses.FrozenInstanceError): + value.limit = 20 # type: ignore[misc] + + +@pytest.mark.parametrize( + ("factory", "match"), + [ + (lambda: BlobLimit(True), "limit"), + (lambda: BlobLimit(-1), "limit"), + (lambda: BlobLimit("1t"), "limit"), + (lambda: BlobLimit("18446744073709551616"), "limit"), + (lambda: TreeDepth(False), "depth"), + (lambda: TreeDepth(-1), "depth"), + (lambda: ObjectType(t.cast(t.Any, "delta")), "type"), + (lambda: SparseOid(""), "oid"), + (lambda: SparseOid("bad\nref"), "oid"), + (lambda: Combine(()), "filters"), + (lambda: Combine((Auto(),)), "auto"), + ], +) +def test_filter_model_rejects_invalid_fields( + factory: t.Callable[[], object], + match: str, +) -> None: + """Direct model construction enforces the Git grammar.""" + with pytest.raises(ValueError, match=match): + factory() + + +@pytest.mark.parametrize( + ("value", "match"), + [ + (None, "filter"), + ([], "nonempty"), + (["blob:none", "auto"], r"filter\[1\].*auto"), + (["blob:none", "wat"], r"filter\[1\]"), + ({"kind": "blob:none", "depth": 1}, "depth"), + ({"kind": "blob:limit"}, "limit"), + ({"kind": "tree", "limit": 1}, "limit"), + ({"kind": "unknown"}, "kind"), + ({"kind": "combine", "filters": []}, "filters"), + ({"kind": "combine", "filters": "blob:none"}, "filters"), + ], +) +def test_filter_coercion_rejects_invalid_config( + value: object, + match: str, +) -> None: + """Config errors name the bad field or list item.""" + with pytest.raises(ValueError, match=match): + coerce_filter(value) + + +@pytest.mark.parametrize( + "spec", + [ + "", + "blob:limit=-1", + "blob:limit=true", + "tree:-1", + "object:type=delta", + "sparse:oid=", + "blob:none\n", + "sparse:oid=bad\x00ref", + "combine:", + "combine:+blob:none", + "combine:blob:none+", + "combine:auto", + "combine:blob%", + "combine:blob%2", + "combine:blob%zz", + "combine:blob:none tree:1", + "combine:sparse:oid=bad%00ref", + ], +) +def test_parse_filter_rejects_malformed_specs(spec: str) -> None: + """Malformed filter specs fail without consulting Git.""" + with pytest.raises(ValueError): + parse_filter(spec) + + +def test_filter_specs_treats_none_as_no_filter() -> None: + """Only the optional command serializer accepts None.""" + assert filter_specs(None) == () + + +def test_filter_recursion_is_bounded() -> None: + """Nested config cannot recurse without limit.""" + value: dict[str, object] = {"kind": "blob:none"} + for _ in range(40): + value = {"kind": "combine", "filters": [value]} + + with pytest.raises(ValueError, match="nesting"): + coerce_filter(value) + + +def test_filter_model_recursion_is_bounded() -> None: + """Direct model construction has the same nesting limit as parsing.""" + value: GitFilter = BlobNone() + + with pytest.raises(ValueError, match="nesting"): + for _ in range(40): + value = Combine((value,)) diff --git a/tests/cmd/test_hg.py b/tests/cmd/test_hg.py index ab3684874..c9e2aa6a0 100644 --- a/tests/cmd/test_hg.py +++ b/tests/cmd/test_hg.py @@ -17,6 +17,26 @@ pytestmark = pytest.mark.skip(reason="hg is not available") +def test_hg_run_keeps_global_options_before_separator( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Mercurial global options precede command operands and their separator.""" + repo = Hg(path=tmp_path) + mock_run = mocker.patch("libvcs.cmd.hg.run", return_value="") + + repo.run(["clone", "--", "source", "destination"], quiet=True) + + assert mock_run.call_args.kwargs["args"] == [ + "hg", + "--quiet", + "clone", + "--", + "source", + "destination", + ] + + def test_hg_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None: """Mercurial run() should not split scalar command strings.""" repo = Hg(path=tmp_path) @@ -26,6 +46,26 @@ def test_hg_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None: assert "Mercurial Distributed SCM" in result +def test_hg_pull_forwards_transport_options( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Mercurial pull emits configured SSH and TLS transport flags.""" + repo = Hg(path=tmp_path) + mock_run = mocker.patch.object(repo, "run", return_value="") + + repo.pull(ssh="ssh -i key", remote_cmd="hg-custom", insecure=True) + + assert mock_run.call_args.args[0] == [ + "pull", + "--ssh", + "ssh -i key", + "--remotecmd", + "hg-custom", + "--insecure", + ] + + def test_hg_run_timeout_propagates_to_runner( tmp_path: pathlib.Path, mocker: MockerFixture, diff --git a/tests/cmd/test_svn.py b/tests/cmd/test_svn.py index 452b04fec..faf4528bd 100644 --- a/tests/cmd/test_svn.py +++ b/tests/cmd/test_svn.py @@ -14,6 +14,8 @@ if t.TYPE_CHECKING: from pytest_mock import MockerFixture + from libvcs.sync.svn import SvnSync + if not shutil.which("svn"): pytestmark = pytest.mark.skip(reason="svn is not available") @@ -27,6 +29,14 @@ def test_svn_run_accepts_scalar_string(tmp_path: pathlib.Path) -> None: assert "usage: svn [options] [args]" in result +def test_svn_run_keeps_global_options_before_separator(svn_repo: SvnSync) -> None: + """The default non-interactive flag must not become a second info target.""" + output = svn_repo.cmd.run(["info", "--xml", "--", "."]) + + assert "" in output + assert svn_repo.url in output + + def test_svn_run_timeout_propagates_to_runner( tmp_path: pathlib.Path, mocker: MockerFixture, @@ -41,6 +51,27 @@ def test_svn_run_timeout_propagates_to_runner( assert kwargs.get("timeout") == 2.5 +def test_svn_run_forwards_quiet_and_trust_cert_with_native_spelling( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Global checkout options use the argv accepted by Subversion.""" + repo = Svn(path=tmp_path) + mock_run = mocker.patch("libvcs.cmd.svn.run", return_value="") + + repo.run(["info", "--", "."], quiet=True, trust_server_cert=True) + + assert mock_run.call_args.kwargs["args"] == [ + "svn", + "--quiet", + "--non-interactive", + "--trust-server-cert", + "info", + "--", + ".", + ] + + def test_checkout_rejects_option_like_url(tmp_path: pathlib.Path) -> None: """Reject a checkout URL that svn would parse as an option. diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py index 1fb8abff8..e9204591e 100644 --- a/tests/sync/test_git.py +++ b/tests/sync/test_git.py @@ -7,18 +7,22 @@ import pathlib import random import shutil +import socket import subprocess +import sys import textwrap import time import typing as t from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor import pytest -from libvcs import exc +from libvcs import GitOptions, exc from libvcs._internal.run import run from libvcs._internal.shortcuts import create_project -from libvcs.sync.base import SyncResult +from libvcs.cmd.git_filter import Auto, BlobNone +from libvcs.sync.base import SyncPolicy, SyncResult, SyncTarget from libvcs.sync.git import ( GitRemote, GitStatus, @@ -40,6 +44,663 @@ ProjectTestFactoryRemoteLazyExpected = Callable[..., dict[str, GitRemote]] +@pytest.mark.parametrize("drift", ["follow", "keep", "warn"]) +def test_initial_checkout_respects_target_before_drift_policy( + git_repo: GitSync, + tmp_path: pathlib.Path, + drift: t.Literal["follow", "keep", "warn"], +) -> None: + """A new checkout starts at its configured target even under keep or warn.""" + original = git_repo.get_revision() + git_repo.run(["tag", "pinned"]) + git_repo.run(["commit", "--allow-empty", "-m", "advance"]) + project = GitSync(url=git_repo.path.as_uri(), path=tmp_path / "new-checkout") + result = project.update_repo( + target=SyncTarget(tag="pinned"), policy=SyncPolicy(drift=drift) + ) + assert result.ok, result.errors + position = project.get_position() + assert position.revision == original + assert not position.follows + + +@pytest.mark.parametrize("local_only", [False, True]) +def test_detach_resolves_branch_after_native_fetch( + git_repo: GitSync, + tmp_path: pathlib.Path, + local_only: bool, +) -> None: + """Detachment uses refreshed tracking refs or the native local fallback.""" + branch = git_repo.get_position().ref_name + if local_only: + branch = "local-only" + git_repo.run(["checkout", "-b", branch]) + git_repo.run(["commit", "--allow-empty", "-m", "local work"]) + expected = git_repo.get_revision() + else: + upstream = GitSync( + url=git_repo.path.as_uri(), path=tmp_path / "private-upstream" + ) + upstream.obtain() + git_repo.run(["remote", "set-url", "origin", upstream.path.as_uri()]) + upstream.run(["commit", "--allow-empty", "-m", "next"]) + expected = upstream.get_revision() + result = git_repo.update_repo(target=SyncTarget(branch=branch), detach=True) + assert result.ok, result.errors + position = git_repo.get_position() + assert position.revision == expected + assert not position.follows + + +@pytest.fixture +def worktree_git_repo(git_repo: GitSync, tmp_path: pathlib.Path) -> GitSync: + """Give creation tests a private upstream for branch pushes.""" + upstream = tmp_path / "upstream.git" + git_repo.run(["clone", "--bare", git_repo.url, str(upstream)]) + project = GitSync(url=upstream.as_uri(), path=git_repo.path) + project.set_remotes(overwrite=True) + return project + + +@pytest.mark.parametrize( + "state", ["behind", "ahead", "diverged", "attached", "occupied"] +) +def test_create_worktree_preserves_existing_branch( + worktree_git_repo: GitSync, tmp_path: pathlib.Path, state: str +) -> None: + """Creation advances behind branches but never loses commits or caller data.""" + git_repo = worktree_git_repo + base = git_repo.get_revision() + git_repo.run(["branch", "chosen"]) + git_repo.run(["commit", "--allow-empty", "-m", "remote successor"]) + desired = git_repo.get_revision() + git_repo.run(["push", "--force", "origin", "HEAD:chosen"]) + git_repo.run(["checkout", "--detach", base]) + if state in {"ahead", "diverged"}: + git_repo.run(["checkout", "chosen"]) + if state == "ahead": + git_repo.run(["merge", "--ff-only", desired]) + git_repo.run(["commit", "--allow-empty", "-m", "local work"]) + git_repo.run(["checkout", "--detach", base]) + elif state == "attached": + git_repo.run(["checkout", "chosen"]) + local = git_repo.run(["rev-parse", "refs/heads/chosen"]).strip() + destination = tmp_path / "created" + if state == "occupied": + destination.mkdir() + (destination / "caller").write_bytes(b"caller bytes\x00") + (git_repo.path / "ordinary-dirt").write_bytes(b"untouched parent\x00") + before = git_repo.get_position() + result = git_repo.create_worktree(destination, target=SyncTarget(branch="chosen")) + assert git_repo.get_position() == before + assert (git_repo.path / "ordinary-dirt").read_bytes() == b"untouched parent\x00" + if state in {"diverged", "attached", "occupied"}: + assert not result.ok + assert git_repo.run(["rev-parse", "refs/heads/chosen"]).strip() == local + if state == "occupied": + assert (destination / "caller").read_bytes() == b"caller bytes\x00" + else: + assert not destination.exists() + else: + assert result.ok, result.errors + assert result.update_state == "completed" + created = GitSync(url=git_repo.url, path=destination).get_position() + assert created.ref_name == "chosen" + assert created.revision == (local if state == "ahead" else desired) + + +@pytest.mark.parametrize( + "selector", ["branch", "tag", "commit", "rev", "remote", "local", "detach"] +) +def test_create_worktree_resolves_typed_target( + worktree_git_repo: GitSync, tmp_path: pathlib.Path, selector: str +) -> None: + """Typed identities resolve after fetch; parent dirt and attachment survive.""" + git_repo = worktree_git_repo + base = git_repo.get_revision() + git_repo.run(["tag", "chosen"]) + git_repo.run(["commit", "--allow-empty", "-m", "branch successor"]) + desired = git_repo.get_revision() + git_repo.run(["push", "--force", "origin", "HEAD:chosen"]) + git_repo.run(["update-ref", "-d", "refs/remotes/origin/chosen"]) + git_repo.run(["branch", "local-only"]) + git_repo.run(["checkout", "--detach", base]) + targets = { + "branch": SyncTarget(branch="chosen"), + "tag": SyncTarget(tag="chosen"), + "commit": SyncTarget(commit=base), + "rev": SyncTarget(rev="chosen"), + "remote": SyncTarget(branch="chosen", remote="selected"), + "local": SyncTarget(branch="local-only"), + "detach": SyncTarget(branch="chosen"), + } + selected_url = git_repo.url + if selector == "remote": + alternate = tmp_path / "alternate.git" + git_repo.run(["clone", "--bare", git_repo.url, str(alternate)]) + git_repo.run( + [ + "--git-dir", + str(alternate), + "update-ref", + "refs/heads/chosen", + base, + ] + ) + selected_url = alternate.as_uri() + git_repo = GitSync( + url=git_repo.url, + path=git_repo.path, + remotes={"selected": GitRemote("selected", selected_url, "unavailable-push")}, + ) + destination = tmp_path / "created" + result = git_repo.create_worktree( + destination, + target=targets[selector], + detach=selector == "detach", + set_remotes=selector == "remote", + lock=selector != "remote", + lock_reason="keep checkout", + ) + assert result.ok, result.errors + position = GitSync(url=git_repo.url, path=destination).get_position() + fixed = selector in {"tag", "commit", "rev"} + assert position.revision == (base if fixed or selector == "remote" else desired) + assert position.follows == (not fixed and selector != "detach") + listing = git_repo.run(["worktree", "list", "--porcelain"]) + assert "locked keep checkout" in listing + + +@pytest.mark.parametrize("linked_owner", [False, True]) +@pytest.mark.parametrize("damaged", [False, True]) +def test_create_worktree_refuses_retained_owner( + git_repo: GitSync, tmp_path: pathlib.Path, damaged: bool, linked_owner: bool +) -> None: + """An interrupted linked owner blocks creation before remotes or destination.""" + from libvcs._internal import preservation + + owner = git_repo + if linked_owner: + linked = tmp_path / "linked" + git_repo.run(["worktree", "add", "--detach", str(linked)]) + owner = GitSync(url=git_repo.url, path=linked) + store = owner._store() + token, record = store.create(original={}, target={}) + if linked_owner: + preservation.atomic_record( + store.repository / ".libvcs-preserve-active.json", + { + "source": str(store.source), + "token": { + "id": token.id, + "backend": token.backend, + "location": token.location, + }, + }, + ) + if damaged: + record["phase"] = [] + preservation.atomic_record(store.token_path(token) / "operation.json", record) + git_repo = GitSync( + url=git_repo.url, path=git_repo.path, remotes={"forbidden": git_repo.url} + ) + before = git_repo.run(["show-ref"]) + destination = tmp_path / "created" + result = git_repo.create_worktree( + destination, + target=SyncTarget(commit=git_repo.get_revision()), + set_remotes=True, + ) + assert not result.ok + assert result.recovery == token + assert result.update_state == "unknown" + assert not destination.exists() + assert "forbidden" not in git_repo.run(["remote"]).splitlines() + assert git_repo.run(["show-ref"]) == before + + +def test_create_worktree_initializes_submodules( + worktree_git_repo: GitSync, tmp_path: pathlib.Path +) -> None: + """New worktrees initialize submodules without disturbing parent local bytes.""" + git_repo = worktree_git_repo + git_repo.run( + ["-c", "protocol.file.allow=always", "submodule", "add", git_repo.url, "child"] + ) + git_repo.run(["commit", "-am", "add child"]) + (git_repo.path / "child" / "local").write_bytes(b"parent child bytes\x00") + destination = tmp_path / "created" + result = git_repo.create_worktree( + destination, + target=SyncTarget(commit=git_repo.get_revision()), + ) + assert result.ok, result.errors + expected = git_repo.run(["-C", "child", "rev-parse", "HEAD"]).strip() + actual = git_repo.run( + ["-C", str(destination / "child"), "rev-parse", "HEAD"] + ).strip() + assert actual == expected + assert (destination / "child" / ".git").is_file() + assert not (destination / "child" / "local").exists() + assert (git_repo.path / "child" / "local").read_bytes() == b"parent child bytes\x00" + + +def test_create_worktree_reports_failed_lock_after_creation( + git_repo: GitSync, tmp_path: pathlib.Path +) -> None: + """A native setup failure reports the checkout that already exists.""" + hook = git_repo.path / ".git" / "hooks" / "post-checkout" + hook.write_text("#!/bin/sh\nexec git worktree lock --reason native-hook .\n") + hook.chmod(0o755) + destination = tmp_path / "created" + result = git_repo.create_worktree( + destination, + target=SyncTarget(commit=git_repo.get_revision()), + lock=True, + ) + assert not result.ok + assert result.update_state == "completed" + assert result.errors[0].step == "worktree-lock" + assert ( + GitSync(url=git_repo.url, path=destination).get_revision() + == git_repo.get_revision() + ) + assert "locked native-hook" in git_repo.run(["worktree", "list", "--porcelain"]) + + +@pytest.mark.slow # Native hook barrier tests competing writers. +def test_create_worktree_owns_native_add( + git_repo: GitSync, tmp_path: pathlib.Path +) -> None: + """A native post-checkout hook observes common ownership until creation ends.""" + destination = tmp_path / "created" + other_destination = tmp_path / "forbidden" + hook = git_repo.path / ".git" / "hooks" / "post-checkout" + with socket.socket() as server: + server.bind(("127.0.0.1", 0)) + server.listen(1) + server.settimeout(10) + hook.write_text( + f"#!{sys.executable}\nimport os, socket, sys\n" + f"if os.getcwd() != {str(destination)!r}: sys.exit(0)\n" + f"address = {server.getsockname()!r}\n" + "with socket.create_connection(address, timeout=10) as connection:\n" + " connection.sendall(b'ready')\n" + " assert connection.recv(1) == b'x'\n" + ) + hook.chmod(0o755) + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit( + git_repo.create_worktree, + destination, + target=SyncTarget(commit=git_repo.get_revision()), + ) + with server.accept()[0] as connection: + assert connection.recv(5) == b"ready" + assert (destination / ".git").is_file() + contender = GitSync( + url=git_repo.url, + path=git_repo.path, + remotes={"forbidden": git_repo.url}, + ) + try: + refused = contender.create_worktree( + other_destination, + target=SyncTarget(commit=git_repo.get_revision()), + set_remotes=True, + ) + assert not refused.ok + assert "ownership is busy" in refused.errors[0].message + assert "forbidden" not in git_repo.run(["remote"]).splitlines() + assert not other_destination.exists() + refused_update = contender.update_repo(set_remotes=True) + assert not refused_update.ok + assert "ownership is busy" in refused_update.errors[0].message + finally: + connection.sendall(b"x") + result = future.result(timeout=10) + assert result.ok, result.errors + assert (destination / ".git").is_file() + + +def test_obtain_reports_clone_failure(tmp_path: pathlib.Path) -> None: + """A missing remote must fail at clone, before submodule or remote setup.""" + missing_remote = tmp_path / "missing-remote" + repo = GitSync(url=str(missing_remote), path=tmp_path / "checkout") + + with pytest.raises(exc.CommandError) as error: + repo.obtain() + + assert "git clone " in error.value.cmd + assert str(missing_remote) in error.value.output + assert "does not exist" in error.value.output + + +def test_git_position_distinguishes_attached_and_detached(git_repo: GitSync) -> None: + """Position reports a moving branch or an immutable detached commit.""" + branch = git_repo.cmd.run(["symbolic-ref", "--short", "HEAD"]).strip() + revision = git_repo.get_revision() + position = git_repo.get_position() + + assert (position.ref_kind, position.ref_name) == ("branch", branch) + assert position.revision == revision + assert position.follows + + git_repo.cmd.run(["tag", branch], check_returncode=True) + assert git_repo.get_position().ref_name == branch + + git_repo.cmd.run(["checkout", "--detach", "HEAD"], check_returncode=True) + position = git_repo.get_position() + + assert (position.ref_kind, position.ref_name) == ("commit", revision) + assert position.revision == revision + assert not position.follows + + +def test_git_remotes_keep_fetch_and_push_destinations( + tmp_path: pathlib.Path, + git_remote_repo: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setting a separate push URL never changes where the repository fetches.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + fetch_url = git_remote_repo.as_uri() + push_url = (tmp_path / "push.git").as_uri() + repo = GitSync( + url=fetch_url, + path=tmp_path / "copy", + remotes={"origin": GitRemote("origin", fetch_url, push_url)}, + ) + + repo.obtain() + + assert repo.remote("origin") == GitRemote("origin", fetch_url, push_url) + + +def test_git_sync_rejects_invalid_filter_before_destination( + tmp_path: pathlib.Path, +) -> None: + """GitSync validates filters without touching the destination.""" + destination = tmp_path / "checkout" + + with pytest.raises(ValueError, match="limit"): + GitSync( + url="file:///unused", + path=destination, + options=GitOptions(filter={"kind": "blob:limit", "limit": False}), + ) + + assert not destination.exists() + + +def test_git_sync_accepts_auto_for_existing_repo_without_submodules( + git_repo: GitSync, +) -> None: + """Auto is valid when an existing checkout has no submodule workload.""" + repo = GitSync( + url=git_repo.url, + path=git_repo.path, + options=GitOptions(filter=Auto()), + ) + + assert repo.options.filter == Auto() + assert repo.cmd.submodules.ls() == [] + + +def test_git_sync_auto_clone_skips_filter_when_no_submodules( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Auto reaches clone and is omitted from an empty submodule update.""" + repo = GitSync( + url="https://example.com/repo.git", + path=tmp_path / "checkout", + options=GitOptions(filter=Auto()), + ) + clone = mocker.patch.object(repo.cmd, "clone", return_value="") + ls_files = mocker.patch.object( + repo.cmd, + "run", + return_value="100644 deadbeef 0\t.gitmodules\0", + ) + mocker.patch.object(repo.cmd.submodule, "init", return_value="") + update = mocker.patch.object(repo.cmd.submodule, "update", return_value="") + mocker.patch.object(repo, "set_remotes") + + repo.obtain() + + assert clone.call_args.kwargs["_filter"] == Auto() + ls_files.assert_called_once_with( + ["ls-files", "--stage", "-z"], check_returncode=True + ) + assert update.call_args.kwargs["_filter"] is None + + +def test_git_sync_auto_clone_rejects_present_submodules( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Auto reports the unsupported submodule workload after cloning.""" + repo = GitSync( + url="https://example.com/repo.git", + path=tmp_path / "checkout", + options=GitOptions(filter=Auto()), + ) + clone = mocker.patch.object(repo.cmd, "clone", return_value="") + mocker.patch.object( + repo.cmd, + "run", + return_value="160000 deadbeef 0\tdeps/sub\0", + ) + init = mocker.patch.object(repo.cmd.submodule, "init", return_value="") + update = mocker.patch.object(repo.cmd.submodule, "update", return_value="") + + with pytest.raises(ValueError, match=r"auto.*submodule"): + repo.obtain() + + clone.assert_called_once() + init.assert_not_called() + update.assert_not_called() + + +def test_git_sync_obtain_partial_clone( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + git_commit_envvars: GitCommitEnvVars, +) -> None: + """GitSync obtains history while leaving old blobs promised by the remote.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + monkeypatch.delenv("GIT_CONFIG_GLOBAL", raising=False) + remote = tmp_path / "remote" + run(["git", "init", str(remote)], env=git_commit_envvars) + run(["git", "config", "uploadpack.allowFilter", "true"], cwd=remote) + run(["git", "config", "uploadpack.allowAnySHA1InWant", "true"], cwd=remote) + tracked = remote / "tracked.txt" + for version in range(3): + tracked.write_text(f"version {version}\n", encoding="utf-8") + run(["git", "add", "tracked.txt"], cwd=remote, env=git_commit_envvars) + run( + ["git", "commit", "-m", f"version {version}"], + cwd=remote, + env=git_commit_envvars, + ) + + destination = tmp_path / "checkout" + repo = GitSync( + url=remote.as_uri(), + path=destination, + options=GitOptions(filter=BlobNone()), + ) + repo.obtain() + + assert ( + repo.cmd.run(["config", "--get", "remote.origin.partialclonefilter"], trim=True) + == "blob:none" + ) + assert repo.cmd.run(["rev-list", "--count", "HEAD"], trim=True) == "3" + missing = repo.cmd.run(["rev-list", "--objects", "--missing=print", "--all"]) + assert any(line.startswith("?") for line in missing.splitlines()) + assert (destination / "tracked.txt").read_text(encoding="utf-8") == "version 2\n" + assert repo.cmd.run(["ls-remote", "origin"]).strip() + + +@pytest.mark.parametrize("depth", [None, 3]) +def test_git_sync_obtain_forwards_filter_to_submodule( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + git_commit_envvars: GitCommitEnvVars, + depth: int | None, +) -> None: + """GitSync creates submodules as partial clones with the configured filter.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + monkeypatch.delenv("GIT_CONFIG_GLOBAL", raising=False) + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + + submodule_remote = tmp_path / "submodule-remote" + run(["git", "init", str(submodule_remote)], env=git_commit_envvars) + run(["git", "config", "uploadpack.allowFilter", "true"], cwd=submodule_remote) + run( + ["git", "config", "uploadpack.allowAnySHA1InWant", "true"], + cwd=submodule_remote, + ) + submodule_file = submodule_remote / "data.txt" + for version in range(3): + submodule_file.write_text(f"submodule data {version}\n", encoding="utf-8") + run(["git", "add", "data.txt"], cwd=submodule_remote, env=git_commit_envvars) + run( + ["git", "commit", "-m", f"submodule data {version}"], + cwd=submodule_remote, + env=git_commit_envvars, + ) + + run( + [ + "git", + "-c", + "protocol.file.allow=always", + "submodule", + "add", + submodule_remote.as_uri(), + "nested", + ], + cwd=submodule_remote, + env=git_commit_envvars, + ) + run( + ["git", "commit", "-m", "add nested submodule"], + cwd=submodule_remote, + env=git_commit_envvars, + ) + + parent_remote = tmp_path / "parent-remote" + run(["git", "init", str(parent_remote)], env=git_commit_envvars) + run(["git", "config", "uploadpack.allowFilter", "true"], cwd=parent_remote) + run( + ["git", "config", "uploadpack.allowAnySHA1InWant", "true"], + cwd=parent_remote, + ) + run( + [ + "git", + "-c", + "protocol.file.allow=always", + "submodule", + "add", + submodule_remote.as_uri(), + "deps/sub", + ], + cwd=parent_remote, + env=git_commit_envvars, + ) + run( + ["git", "commit", "-m", "add submodule"], + cwd=parent_remote, + env=git_commit_envvars, + ) + + destination = tmp_path / "checkout" + repo = GitSync( + url=parent_remote.as_uri(), + path=destination, + options=GitOptions( + depth=depth, + filter=[BlobNone(), {"kind": "tree", "depth": 2}], + ), + ) + repo.obtain() + + assert (destination / "deps" / "sub" / "data.txt").read_text( + encoding="utf-8" + ) == "submodule data 2\n" + assert (destination / "deps" / "sub" / "nested" / "data.txt").read_text( + encoding="utf-8" + ) == "submodule data 2\n" + assert run( + ["git", "rev-list", "--count", "HEAD"], + cwd=destination / "deps" / "sub", + ).strip() == str(depth or 4) + submodule_git_dir = destination / ".git" / "modules" / "deps" / "sub" + assert ( + run( + ["git", "config", "--get", "remote.origin.partialclonefilter"], + cwd=submodule_git_dir, + ).strip() + == "combine:blob:none+tree:2" + ) + missing = run( + ["git", "rev-list", "--objects", "--missing=print", "--all"], + cwd=destination / "deps" / "sub", + ) + assert any(line.startswith("?") for line in missing.splitlines()) + + run( + [ + "git", + "-c", + "protocol.file.allow=always", + "submodule", + "add", + submodule_remote.as_uri(), + "deps/late", + ], + cwd=parent_remote, + env=git_commit_envvars, + ) + run( + ["git", "commit", "-m", "add late submodule"], + cwd=parent_remote, + env=git_commit_envvars, + ) + result = repo.update_repo() + assert result.ok, result.errors + late_submodule_git_dir = destination / ".git" / "modules" / "deps" / "late" + assert ( + run( + ["git", "config", "--get", "remote.origin.partialclonefilter"], + cwd=late_submodule_git_dir, + ).strip() + == "combine:blob:none+tree:2" + ) + + submodule_file.write_text("submodule data 3\n") + run( + ["git", "commit", "-am", "submodule update"], + cwd=submodule_remote, + env=git_commit_envvars, + ) + run(["git", "submodule", "update", "--remote", "deps/sub"], cwd=parent_remote) + run( + ["git", "commit", "-am", "advance submodule"], + cwd=parent_remote, + env=git_commit_envvars, + ) + result = repo.update_repo() + assert result.ok, result.errors + assert ( + destination / "deps" / "sub" / "data.txt" + ).read_text() == "submodule data 3\n" + + @pytest.mark.parametrize( # Postpone evaluation of options so fixture variables can interpolate ("constructor", "lazy_constructor_options"), @@ -49,7 +710,6 @@ lambda bare_dir, tmp_path, **kwargs: { "url": bare_dir.as_uri(), "path": tmp_path / "obtaining a bare repo", - "vcs": "git", }, ), ( @@ -92,7 +752,6 @@ def test_repo_git_obtain_initial_commit_repo( lambda git_remote_repo, tmp_path, **kwargs: { "url": git_remote_repo.as_uri(), "path": tmp_path / "myrepo", - "vcs": "git", }, ), ( @@ -121,34 +780,17 @@ def test_repo_git_obtain_full( assert (tmp_path / "myrepo").exists() -def test_git_shallow_and_tls_verify_kwargs_are_honored( +def test_git_options_depth_one_creates_shallow_clone( tmp_path: pathlib.Path, git_remote_repo: pathlib.Path, ) -> None: - """``git_shallow`` and ``tls_verify`` populate their attributes. - - Regression: each kwarg previously left its attribute unset, so the next - ``obtain()`` raised ``AttributeError``. - """ - # tls_verify reaches the attribute. Its clone-time ``config`` wiring is - # broken independently of this fix and tracked separately, so we don't - # drive a clone with it here. - assert ( - GitSync( - url=git_remote_repo.as_uri(), - path=tmp_path / "tls", - tls_verify=True, - ).tls_verify - is True - ) - - # git_shallow drives a depth-1 (shallow) clone in obtain(). + """A depth-one option preserves the former shallow-clone behavior.""" git_repo = GitSync( url=git_remote_repo.as_uri(), path=tmp_path / "myrepo", - git_shallow=True, + options=GitOptions(depth=1), ) - assert git_repo.git_shallow is True + assert git_repo.options.depth == 1 git_repo.obtain() is_shallow = run( @@ -175,23 +817,17 @@ class DepthFixture(t.NamedTuple): expected_shallow=False, ), DepthFixture( - test_id="git_shallow-depth-1", - sync_kwargs={"git_shallow": True}, + test_id="depth-1", + sync_kwargs={"options": GitOptions(depth=1)}, expected_count=1, expected_shallow=True, ), DepthFixture( test_id="depth-3", - sync_kwargs={"depth": 3}, + sync_kwargs={"options": GitOptions(depth=3)}, expected_count=3, expected_shallow=True, ), - DepthFixture( - test_id="depth-overrides-git_shallow", - sync_kwargs={"git_shallow": True, "depth": 2}, - expected_count=2, - expected_shallow=True, - ), ] @@ -240,7 +876,6 @@ def test_obtain_honors_clone_depth( lambda git_remote_repo, tmp_path, **kwargs: { "url": git_remote_repo.as_uri(), "path": tmp_path / "myrepo", - "vcs": "git", }, ), ( @@ -264,20 +899,13 @@ def test_repo_update_handle_cases( git_repo: GitSync = constructor(**lazy_constructor_options(**locals())) git_repo.obtain() # clone initial repo - cmd_mock = mocker.spy(git_repo.cmd, "symbolic_ref") - git_repo.update_repo() - - cmd_mock.assert_any_call(name="HEAD", short=True, check_returncode=True) - - cmd_mock.reset_mock() - - # will only look up symbolic-ref if no rev specified for object + original = git_repo.get_position() + assert git_repo.update_repo().ok + assert git_repo.get_position() == original git_repo.rev = "HEAD" - git_repo.update_repo() - assert ( - mocker.call(name="HEAD", short=True, check_returncode=True) - not in cmd_mock.mock_calls - ) + assert git_repo.update_repo().ok + assert git_repo.get_position().revision == original.revision + assert git_repo.get_position().ref_kind == "commit" @pytest.mark.parametrize( @@ -307,7 +935,6 @@ def test_repo_update_stash_cases( git_repo: GitSync = GitSync( url=git_bare_repo.as_uri(), path=tmp_path / "myrepo", - vcs="git", ) git_repo.obtain() # clone initial repo @@ -338,10 +965,13 @@ def test_repo_update_stash_cases( ) git_repo.run(["add", some_stashed_file]) - cmd_mock = mocker.spy(git_repo.cmd, "symbolic_ref") - git_repo.update_repo() - - cmd_mock.assert_any_call(name="HEAD", short=True, check_returncode=True) + head = git_repo.get_revision() + before = git_repo.run(["status", "--porcelain=v2", "-z"]) + result = git_repo.update_repo() + assert result.ok is not (has_untracked_files or needs_stash) + if has_untracked_files or needs_stash: + assert git_repo.get_revision() == head + assert git_repo.run(["status", "--porcelain=v2", "-z"]) == before @pytest.mark.parametrize( @@ -354,7 +984,6 @@ def test_repo_update_stash_cases( "url": git_remote_repo.as_uri(), "path": tmp_path / "myrepo", "progress_callback": progress_callback, - "vcs": "git", }, ), ( @@ -507,7 +1136,6 @@ def progress_callback_spy(output: str, timestamp: datetime.datetime) -> None: lambda git_remote_repo, projects_path, repo_name, **kwargs: { "url": git_remote_repo.as_uri(), "path": projects_path / repo_name, - "vcs": "git", "remotes": { "second_remote": GitRemote( name="second_remote", @@ -721,7 +1349,6 @@ def test_git_get_url_and_rev_from_pip_url() -> None: lambda git_remote_repo, path, **kwargs: { "url": git_remote_repo.as_uri(), "path": path, - "vcs": "git", }, ), ( @@ -765,7 +1392,6 @@ def test_remotes_preserves_git_ssh( lambda bare_dir, tmp_path, **kwargs: { "url": bare_dir.as_uri(), "path": tmp_path / "obtaining a bare repo", - "vcs": "git", }, ), ( @@ -1138,7 +1764,7 @@ def test_update_repo_checkout_failure_returns_sync_result( assert result.ok is False assert bool(result) is False assert len(result.errors) > 0 - assert result.errors[0].step == "checkout" + assert result.errors[0].step == "target" assert result.errors[0].exception is not None assert isinstance(result.errors[0].exception, exc.CommandError) @@ -1172,146 +1798,11 @@ def test_update_repo_rev_list_head_failure_returns_sync_result( assert result.ok is False assert bool(result) is False assert len(result.errors) > 0 - assert result.errors[0].step == "rev-list-head" + assert result.errors[0].step == "precondition" assert result.errors[0].exception is not None assert isinstance(result.errors[0].exception, exc.CommandError) -def test_update_repo_submodule_failure_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that submodule.update() failure is recorded in SyncResult. - - When ``git submodule update`` fails, the error should be recorded - in SyncResult rather than propagating as an uncaught exception. - We mock the submodule.update call to raise CommandError since - triggering a real submodule failure is git-version-dependent. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit and push so update_repo has a valid HEAD - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - # Make another commit, push, then reset to create a "behind" state - another_file = git_repo.path / "another_file" - another_file.write_text("more content", encoding="utf-8") - git_repo.run(["add", str(another_file)]) - git_repo.run(["commit", "-m", "second commit"]) - git_repo.run(["push"]) - git_repo.run(["reset", "--hard", "HEAD^"]) - - # Mock submodule.update to raise CommandError - mocker.patch.object( - git_repo.cmd.submodule, - "update", - side_effect=exc.CommandError( - output="fatal: clone of 'file:///nonexistent' failed", - returncode=128, - cmd="git submodule update --init --recursive", - ), - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert any(e.step == "submodule-update" for e in result.errors) - - -def test_update_repo_symbolic_ref_failure_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, -) -> None: - """Test that symbolic_ref failure on detached HEAD is recorded in SyncResult. - - When a repo is in detached HEAD state and no ``rev`` is set, - ``symbolic_ref --short HEAD`` fails. Currently this is not wrapped - in try/except, so the exception propagates instead of being - recorded in SyncResult. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit and push so the repo has a valid HEAD - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - # Detach HEAD — symbolic_ref will fail - head_sha = git_repo.run(["rev-parse", "HEAD"]).strip() - git_repo.run(["checkout", head_sha]) - - # Ensure no rev is set so the code path hits symbolic_ref - git_repo.rev = None - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert any(e.step == "symbolic-ref" for e in result.errors) - - -def test_update_repo_remote_ref_not_found_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, -) -> None: - """Test that GitRemoteRefNotFound is caught and recorded in SyncResult. - - When show-ref output contains ``refs/remotes/`` but the regex - match fails, ``GitRemoteRefNotFound`` is raised with a bare ``raise``. - It should instead be caught and recorded in SyncResult. - """ - from unittest.mock import patch - - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit and push so the repo has a valid HEAD - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - # Set rev so symbolic_ref is skipped - git_repo.rev = "master" - - # Patch show_ref to return output that contains "refs/remotes/master" - # but in a format that the regex won't match, triggering - # GitRemoteRefNotFound - malformed_show_ref = "not-a-sha refs/remotes/master" - with patch.object(git_repo.cmd, "show_ref", return_value=malformed_show_ref): - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert any(e.step == "remote-ref-not-found" for e in result.errors) - - def test_update_repo_obtain_failure_recorded( create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, @@ -1393,379 +1884,6 @@ def test_update_repo_set_remotes_failure_recorded( assert isinstance(result.errors[0].exception, exc.CommandError) -def test_update_repo_remote_name_failure_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that get_current_remote_name failure is recorded in SyncResult. - - When ``get_current_remote_name()`` raises, the error is recorded - as ``remote-name``. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit and push so the repo has a valid HEAD - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - mocker.patch.object( - git_repo, - "get_current_remote_name", - side_effect=exc.CommandError( - output="fatal: could not determine remote", - returncode=1, - cmd="git status", - ), - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert result.errors[0].step == "remote-name" - assert result.errors[0].exception is not None - - -def test_update_repo_status_failure_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that cmd.status failure is recorded in SyncResult. - - When ``cmd.status()`` raises during the dirty-tree check on a remote - ref, the error is recorded as ``status``. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit, push, then reset to create a "behind" state - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - another_file = git_repo.path / "another_file" - another_file.write_text("more content", encoding="utf-8") - git_repo.run(["add", str(another_file)]) - git_repo.run(["commit", "-m", "second commit"]) - git_repo.run(["push"]) - git_repo.run(["reset", "--hard", "HEAD^"]) - - # cmd.status is called twice: once for get_current_remote_name - # (with short=True, branch=True) and once for the dirty-tree check - # (with porcelain=True, untracked_files="no"). Only the second should fail. - real_status = git_repo.cmd.status - - def status_side_effect(**kwargs: t.Any) -> str: - if "untracked_files" in kwargs: - raise exc.CommandError( - output="fatal: status failed", - returncode=128, - cmd="git status", - ) - return real_status(**kwargs) - - mocker.patch.object( - git_repo.cmd, - "status", - side_effect=status_side_effect, - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert result.errors[0].step == "status" - assert result.errors[0].exception is not None - assert isinstance(result.errors[0].exception, exc.CommandError) - - -def test_update_repo_stash_save_failure_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that stash save failure is recorded in SyncResult. - - When ``cmd.stash.save()`` raises on a dirty working tree, the error - is recorded as ``stash-save``. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit, push, then reset to create a "behind" state - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - another_file = git_repo.path / "another_file" - another_file.write_text("more content", encoding="utf-8") - git_repo.run(["add", str(another_file)]) - git_repo.run(["commit", "-m", "second commit"]) - git_repo.run(["push"]) - git_repo.run(["reset", "--hard", "HEAD^"]) - - # cmd.status is called twice: first for get_current_remote_name, then - # for the dirty-tree check. Only the second should return dirty. - real_status = git_repo.cmd.status - - def status_side_effect(**kwargs: t.Any) -> str: - if "untracked_files" in kwargs: - return "M some_file" - return real_status(**kwargs) - - mocker.patch.object( - git_repo.cmd, - "status", - side_effect=status_side_effect, - ) - - mocker.patch.object( - git_repo.cmd.stash, - "save", - side_effect=exc.CommandError( - output="fatal: stash save failed", - returncode=1, - cmd="git stash save", - ), - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert result.errors[0].step == "stash-save" - assert result.errors[0].exception is not None - assert isinstance(result.errors[0].exception, exc.CommandError) - - -def test_update_repo_rebase_invalid_upstream_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that rebase with invalid upstream is recorded in SyncResult. - - When ``cmd.rebase()`` raises and the error message contains - ``invalid_upstream``, the error is recorded as ``rebase`` and the - function returns immediately. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit, push, then reset to create a "behind" state - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - another_file = git_repo.path / "another_file" - another_file.write_text("more content", encoding="utf-8") - git_repo.run(["add", str(another_file)]) - git_repo.run(["commit", "-m", "second commit"]) - git_repo.run(["push"]) - git_repo.run(["reset", "--hard", "HEAD^"]) - - # cmd.status is called twice: first for get_current_remote_name, then - # for the dirty-tree check. Only the second should return dirty. - real_status = git_repo.cmd.status - - def status_side_effect(**kwargs: t.Any) -> str: - if "untracked_files" in kwargs: - return "M some_file" - return real_status(**kwargs) - - mocker.patch.object( - git_repo.cmd, - "status", - side_effect=status_side_effect, - ) - - mocker.patch.object( - git_repo.cmd, - "rebase", - side_effect=exc.CommandError( - output="fatal: invalid_upstream 'origin/master'", - returncode=128, - cmd="git rebase", - ), - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert result.errors[0].step == "rebase" - assert "invalid_upstream" in result.errors[0].message - assert result.errors[0].exception is not None - assert isinstance(result.errors[0].exception, exc.CommandError) - - -def test_update_repo_rebase_conflict_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that rebase conflict (non-abort) is recorded in SyncResult. - - When ``cmd.rebase()`` raises and the error does NOT contain - ``invalid_upstream`` or ``Aborting``, the function aborts the rebase, - tries to restore stash, and records the error as ``rebase``. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit, push, then reset to create a "behind" state - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - another_file = git_repo.path / "another_file" - another_file.write_text("more content", encoding="utf-8") - git_repo.run(["add", str(another_file)]) - git_repo.run(["commit", "-m", "second commit"]) - git_repo.run(["push"]) - git_repo.run(["reset", "--hard", "HEAD^"]) - - # cmd.status is called twice: first for get_current_remote_name, then - # for the dirty-tree check. Only the second should return dirty. - real_status = git_repo.cmd.status - - def status_side_effect(**kwargs: t.Any) -> str: - if "untracked_files" in kwargs: - return "M some_file" - return real_status(**kwargs) - - mocker.patch.object( - git_repo.cmd, - "status", - side_effect=status_side_effect, - ) - - mocker.patch.object( - git_repo.cmd, - "rebase", - side_effect=exc.CommandError( - output="CONFLICT (content): Merge conflict in file.txt", - returncode=1, - cmd="git rebase", - ), - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert result.errors[0].step == "rebase" - assert "CONFLICT" in result.errors[0].message - assert result.errors[0].exception is not None - assert isinstance(result.errors[0].exception, exc.CommandError) - - -def test_update_repo_stash_pop_failure_recorded( - create_git_remote_bare_repo: CreateRepoFn, - tmp_path: pathlib.Path, - mocker: MockerFixture, -) -> None: - """Test that stash pop failure is recorded in SyncResult. - - When ``cmd.stash.pop()`` raises after a successful rebase on a dirty - tree, the error is recorded as ``stash-pop``. - """ - git_server = create_git_remote_bare_repo() - git_repo = GitSync( - path=tmp_path / "myrepo", - url=git_server.as_uri(), - ) - git_repo.obtain() - - # Make a commit, push, then reset to create a "behind" state - initial_file = git_repo.path / "initial_file" - initial_file.write_text("content", encoding="utf-8") - git_repo.run(["add", str(initial_file)]) - git_repo.run(["commit", "-m", "initial commit"]) - git_repo.run(["push"]) - - another_file = git_repo.path / "another_file" - another_file.write_text("more content", encoding="utf-8") - git_repo.run(["add", str(another_file)]) - git_repo.run(["commit", "-m", "second commit"]) - git_repo.run(["push"]) - git_repo.run(["reset", "--hard", "HEAD^"]) - - # cmd.status is called twice: first for get_current_remote_name, then - # for the dirty-tree check. Only the second should return dirty. - real_status = git_repo.cmd.status - - def status_side_effect(**kwargs: t.Any) -> str: - if "untracked_files" in kwargs: - return "M some_file" - return real_status(**kwargs) - - mocker.patch.object( - git_repo.cmd, - "status", - side_effect=status_side_effect, - ) - - # stash.pop must fail on both attempts (index=True and without) - mocker.patch.object( - git_repo.cmd.stash, - "pop", - side_effect=exc.CommandError( - output="error: could not restore untracked files", - returncode=1, - cmd="git stash pop", - ), - ) - - result = git_repo.update_repo() - - assert isinstance(result, SyncResult) - assert result.ok is False - assert len(result.errors) > 0 - assert result.errors[0].step == "stash-pop" - assert result.errors[0].exception is not None - assert isinstance(result.errors[0].exception, exc.CommandError) - - def test_sync_result_multiple_errors() -> None: """Test that SyncResult can accumulate multiple errors.""" result = SyncResult() @@ -1868,3 +1986,669 @@ def test_update_repo_rejects_option_like_rev( assert not result.ok, "update_repo() should fail for an option-like rev" assert victim.read_text() == "important\n", "Prevent rev argument injection" + + +def _preservation_update(repo: GitSync) -> str: + """Create a fetched successor while leaving the checkout at its base.""" + base = repo.get_revision() + (repo.path / "upstream.txt").write_text("upstream\n") + repo.run(["add", "upstream.txt"]) + repo.run(["commit", "-m", "upstream"]) + repo.run( + ["push", "--set-upstream", "origin", f"HEAD:preservation-{repo.path.name}"] + ) + repo.run(["reset", "--hard", base]) + return base + + +def test_preservation_abort_before_capture(git_repo: GitSync) -> None: + """Default abort leaves dirty contents and HEAD untouched.""" + base = _preservation_update(git_repo) + (git_repo.path / "local.txt").write_text("local\n") + result = git_repo.update_repo() + assert not result.ok + assert result.update_state == "not-started" + assert git_repo.get_revision() == base + assert (git_repo.path / "local.txt").read_text() == "local\n" + + +@pytest.mark.parametrize("detached", [False, True]) +def test_preservation_recovers_index_offline( + git_repo: GitSync, tmp_path: pathlib.Path, detached: bool +) -> None: + """A retained stash recovers both index and working contents at the saved base.""" + base = _preservation_update(git_repo) + target = None + if detached: + target = SyncTarget(commit=git_repo.run(["rev-parse", "@{upstream}"]).strip()) + git_repo.run(["checkout", "--detach", base]) + original = git_repo.get_position() + local = git_repo.path / "local.txt" + local.write_text("staged\n") + git_repo.run(["add", "local.txt"]) + local.write_text("unstaged\n") + result = git_repo.update_repo(target=target, policy=SyncPolicy(dirty="preserve")) + assert result.ok, result.errors + assert result.preservation_state == "restored" + assert result.recovery is not None + assert git_repo.run(["show", ":local.txt"]) == "staged\n" + assert local.read_text() == "unstaged\n" + git_repo.run(["remote", "set-url", "origin", str(tmp_path / "absent")]) + destination = tmp_path / "recovered" + recovered = git_repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + assert GitSync(url=git_repo.url, path=destination).get_position() == original + assert run(["git", "rev-parse", "HEAD"], cwd=destination).strip() == base + assert run(["git", "show", ":local.txt"], cwd=destination) == "staged\n" + assert (destination / "local.txt").read_text() == "unstaged\n" + assert git_repo.list_recoveries()[0].recovery == result.recovery + git_repo.release_changes(result.recovery) + assert git_repo.list_recoveries() == () + + +@pytest.mark.parametrize("drift", ["keep", "warn"]) +def test_preservation_drift_keep_leaves_dirty_branch( + git_repo: GitSync, + drift: t.Literal["keep", "warn"], + caplog: pytest.LogCaptureFixture, +) -> None: + """Keep and warn leave a differing dirty branch intact, with a typed warning.""" + _preservation_update(git_repo) + git_repo.run(["branch", "other", "@{upstream}"]) + (git_repo.path / "local.txt").write_text("local\n") + original = git_repo.get_position() + result = git_repo.update_repo( + target=SyncTarget(branch="other"), policy=SyncPolicy(drift=drift) + ) + assert result.ok, result.errors + assert git_repo.get_position() == original + assert result.recovery is None + warnings = [ + record + for record in caplog.records + if getattr(record, "vcs_event", None) == "target_drift" + ] + assert len(warnings) == (1 if drift == "warn" else 0) + + +def test_preservation_divergence_retains_local_commit(git_repo: GitSync) -> None: + """Follow refuses divergent histories before capturing dirty changes.""" + _preservation_update(git_repo) + (git_repo.path / "commit.txt").write_text("local commit\n") + git_repo.run(["add", "commit.txt"]) + git_repo.run(["commit", "-m", "local"]) + head = git_repo.get_revision() + (git_repo.path / "local.txt").write_text("dirty\n") + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is None + assert git_repo.get_revision() == head + assert (git_repo.path / "local.txt").read_text() == "dirty\n" + + +@pytest.mark.parametrize("selector", ["branch", "tag", "commit"]) +def test_preservation_equal_alias_keeps_attachment( + git_repo: GitSync, selector: str, caplog: pytest.LogCaptureFixture +) -> None: + """Equal object IDs do not warn or change attachment under drift keep/warn.""" + git_repo.run(["branch", "alias"]) + git_repo.run(["tag", "alias"]) + target = SyncTarget( + **{selector: git_repo.get_revision() if selector == "commit" else "alias"} + ) + original = git_repo.get_position() + for drift in ("keep", "warn"): + result = git_repo.update_repo(target=target, policy=SyncPolicy(drift=drift)) + assert result.ok, result.errors + assert git_repo.get_position() == original + assert not [record for record in caplog.records if record.name == "libvcs.sync.git"] + + +@pytest.mark.parametrize( + "fault", ["capture", "update", "restore", "inspection", "publication"] +) +def test_preservation_retains_token_after_fault( + git_repo: GitSync, + monkeypatch: pytest.MonkeyPatch, + fault: str, + tmp_path: pathlib.Path, +) -> None: + """Every failure after capture retains recovery and the first operation error.""" + from libvcs._internal import preservation + + _preservation_update(git_repo) + (git_repo.path / "local.txt").write_text("local\n") + real_run = git_repo.cmd.run + real_read = git_repo._read_git + + def fail_command(args: t.Any, **kwargs: t.Any) -> str: + if (fault == "update" and args[0] == "merge") or ( + fault == "restore" and args[:2] == ["stash", "apply"] + ): + raise exc.CommandError(output=fault, cmd=args, returncode=1) + output = real_run(args, **kwargs) + if fault == "capture" and args[:2] == ["stash", "push"]: + raise exc.CommandError(output=fault, cmd=args, returncode=1) + return output + + def fail_read(args: list[str], **kwargs: t.Any) -> str: + if fault == "inspection" and args[:2] == ["ls-files", "--unmerged"]: + raise exc.CommandError(output=fault, cmd=args, returncode=1) + return real_read(args, **kwargs) + + def fail_finish(*args: t.Any, **kwargs: t.Any) -> None: + message = "publication" + raise OSError(message) + + with monkeypatch.context() as patch: + patch.setattr(git_repo.cmd, "run", fail_command) + patch.setattr(git_repo, "_read_git", fail_read) + if fault == "publication": + patch.setattr(preservation.RecoveryStore, "finish", fail_finish) + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is not None + assert result.errors[0].step == fault + assert git_repo.list_recoveries()[0].recovery == result.recovery + recovered = git_repo.recover_changes( + result.recovery, destination=tmp_path / "recovered" + ) + assert recovered.ok, recovered.errors + assert (tmp_path / "recovered" / "local.txt").read_text() == "local\n" + + +@pytest.mark.parametrize("collision", [False, True]) +def test_preservation_ignored_output(git_repo: GitSync, collision: bool) -> None: + """Ignored output only blocks updates that write an overlapping path.""" + base = _preservation_update(git_repo) + (git_repo.path / ".git" / "info" / "exclude").write_text("*.txt\n") + output = git_repo.path / ("upstream.txt" if collision else "build.txt") + output.write_text("ignored\n") + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.ok is not collision, result.errors + assert output.read_text() == "ignored\n" + assert (git_repo.get_revision() == base) is collision + assert result.recovery is None + + +@pytest.mark.parametrize("kind", ["text", "binary", "unknown", "disjoint"]) +def test_preservation_native_merge_outcomes( + git_repo: GitSync, kind: str, tmp_path: pathlib.Path +) -> None: + """Native indexed apply reports collisions and merges disjoint text changes.""" + file = git_repo.path / "shared" + if kind != "unknown": + file.write_bytes( + b"base\0binary" + if kind == "binary" + else b"first\n" + b"middle\n" * 12 + b"last\n" + ) + git_repo.run(["add", "shared"]) + git_repo.run(["commit", "-m", "base"]) + base = git_repo.get_revision() + file.write_bytes( + b"upstream\0binary" + if kind == "binary" + else b"upstream\n" + b"middle\n" * 12 + b"last\n" + ) + git_repo.run(["add", "shared"]) + git_repo.run(["commit", "-m", "upstream"]) + git_repo.run( + ["push", "--set-upstream", "origin", f"HEAD:merge-{git_repo.path.name}"] + ) + git_repo.run(["reset", "--hard", base]) + local = ( + b"local\0binary" + if kind == "binary" + else ( + b"first\n" + b"middle\n" * 12 + b"local\n" + if kind == "disjoint" + else b"local\n" + ) + ) + file.write_bytes(local) + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.recovery is not None + assert result.ok is (kind == "disjoint"), result.errors + assert result.update_state == "completed" + if kind == "disjoint": + assert file.read_bytes().startswith(b"upstream\n") + assert file.read_bytes().endswith(b"local\n") + recovered = git_repo.recover_changes( + result.recovery, destination=tmp_path / "recovered" + ) + assert recovered.ok, recovered.errors + assert (tmp_path / "recovered" / "shared").read_bytes() == local + assert run(["git", "rev-parse", "HEAD"], cwd=tmp_path / "recovered").strip() == base + + +def test_preservation_unchanged_preserves_existing_stash(git_repo: GitSync) -> None: + """No-change updates never adopt or remove a caller-owned stash.""" + (git_repo.path / "caller").write_text("caller\n") + git_repo.run(["stash", "push", "-u", "-m", "caller"]) + before = git_repo.run(["stash", "list", "--format=%H"]) + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.ok, result.errors + assert result.recovery is None + assert git_repo.run(["stash", "list", "--format=%H"]) == before + + +@pytest.mark.parametrize( + "busy", ["MERGE_HEAD", "rebase-merge", "index.lock", "nested", "submodule"] +) +def test_preservation_native_preconditions(git_repo: GitSync, busy: str) -> None: + """Native activity and independent repositories fail before fetch or capture.""" + _preservation_update(git_repo) + if busy == "nested": + (git_repo.path / "nested" / ".git").mkdir(parents=True) + elif busy == "submodule": + git_repo.run( + [ + "update-index", + "--add", + "--cacheinfo", + f"160000,{git_repo.get_revision()},module", + ] + ) + elif busy == "rebase-merge": + (git_repo.path / ".git" / busy).mkdir() + else: + (git_repo.path / ".git" / busy).write_text(git_repo.get_revision()) + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.errors[0].step == "precondition" + assert result.recovery is None + + +def test_preservation_rename_paths(git_repo: GitSync) -> None: + """NUL status preserves both rename paths including whitespace and newlines.""" + original = git_repo.path / "old\nname" + original.write_text("contents") + git_repo.run(["add", "--", original.name]) + git_repo.run(["commit", "-m", "original"]) + git_repo.run(["mv", "--", original.name, "new name"]) + assert set(git_repo._dirty_paths()) == {"old\nname", "new name"} + + +def test_preservation_interrupted_save_is_discoverable( + git_repo: GitSync, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """An interrupted save retains discoverable intent and native stash identity.""" + _preservation_update(git_repo) + (git_repo.path / "local.txt").write_text("local\n") + real_run = git_repo.cmd.run + + def interrupt(args: t.Any, **kwargs: t.Any) -> str: + output = real_run(args, **kwargs) + if args[:2] == ["stash", "push"]: + raise KeyboardInterrupt + return output + + with monkeypatch.context() as patch: + patch.setattr(git_repo.cmd, "run", interrupt) + with pytest.raises(KeyboardInterrupt): + git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + interrupted = git_repo.list_recoveries()[0] + assert not interrupted.ok + assert interrupted.recovery is not None + assert interrupted.errors[0].step == "interrupted" + blocked = git_repo.update_repo() + assert not blocked.ok + assert blocked.recovery == interrupted.recovery + recovered = git_repo.recover_changes( + interrupted.recovery, destination=tmp_path / "recovered" + ) + assert recovered.ok, recovered.errors + assert (tmp_path / "recovered" / "local.txt").read_text() == "local\n" + + +def test_preservation_release_keeps_caller_stash(git_repo: GitSync) -> None: + """Release identifies the owned stash beneath a newer caller stash.""" + _preservation_update(git_repo) + (git_repo.path / "local.txt").write_text("local\n") + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.ok, result.errors + assert result.recovery is not None + git_repo.run(["stash", "push", "-u", "-m", "caller"]) + caller = git_repo.run(["rev-parse", "refs/stash"]) + git_repo.release_changes(result.recovery) + assert git_repo.run(["rev-parse", "refs/stash"]) == caller + assert git_repo.run(["stash", "list", "--format=%H"]) == caller + + +def test_preservation_missing_promised_objects_fail_offline( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + git_commit_envvars: GitCommitEnvVars, +) -> None: + """Partial history recovery refuses absent blobs without contacting the promisor.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + remote = tmp_path / "remote" + run(["git", "init", str(remote)], env=git_commit_envvars) + run(["git", "config", "uploadpack.allowFilter", "true"], cwd=remote) + for version in range(3): + (remote / "tracked").write_text(f"version {version}\n") + run(["git", "add", "tracked"], cwd=remote) + run( + ["git", "commit", "-m", f"version {version}"], + cwd=remote, + env=git_commit_envvars, + ) + repo = GitSync( + url=remote.as_uri(), + path=tmp_path / "checkout", + options=GitOptions(filter=BlobNone()), + ) + repo.obtain() + (remote / "upstream").write_text("upstream\n") + run(["git", "add", "upstream"], cwd=remote) + run(["git", "commit", "-m", "upstream"], cwd=remote, env=git_commit_envvars) + (repo.path / "local").write_text("local\n") + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.ok, result.errors + assert result.recovery is not None + remote.rename(tmp_path / "offline-remote") + destination = tmp_path / "recovered" + recovered = repo.recover_changes(result.recovery, destination=destination) + assert not recovered.ok + assert recovered.recovery == result.recovery + assert not destination.exists() + assert repo.list_recoveries()[0].recovery == result.recovery + + +def test_preservation_linked_checkout_shares_interruption_guard( + git_repo: GitSync, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Interrupted operations block another worktree sharing the stash namespace.""" + _preservation_update(git_repo) + linked = tmp_path / "linked" + git_repo.run(["worktree", "add", "-b", "linked", str(linked)]) + other = GitSync(url=git_repo.url, path=linked) + (git_repo.path / "local").write_text("local\n") + real_run = git_repo.cmd.run + + def interrupt(args: t.Any, **kwargs: t.Any) -> str: + output = real_run(args, **kwargs) + if args[:2] == ["stash", "push"]: + raise KeyboardInterrupt + return output + + with monkeypatch.context() as patch: + patch.setattr(git_repo.cmd, "run", interrupt) + with pytest.raises(KeyboardInterrupt): + git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + result = other.update_repo() + assert not result.ok + assert result.recovery == git_repo.list_recoveries()[0].recovery + assert result.errors[0].step == "interrupted" + + +def test_preservation_follow_attaches_equal_branch(git_repo: GitSync) -> None: + """Follow may attach an explicit equal-OID branch to govern future updates.""" + git_repo.run(["branch", "alias"]) + result = git_repo.update_repo(target=SyncTarget(branch="alias")) + assert result.ok, result.errors + assert git_repo.get_position().ref_name == "alias" + + +def test_preservation_discard_preserves_commits_and_ignored(git_repo: GitSync) -> None: + """Explicit discard removes ordinary dirt without rewriting local commits.""" + _preservation_update(git_repo) + git_repo.run(["merge", "--ff-only", "@{upstream}"]) + (git_repo.path / "commit").write_text("local commit\n") + git_repo.run(["add", "commit"]) + git_repo.run(["commit", "-m", "local"]) + head = git_repo.get_revision() + (git_repo.path / "commit").write_text("dirt\n") + (git_repo.path / "unknown").write_text("unknown\n") + (git_repo.path / ".git" / "info" / "exclude").write_text("ignored\n") + (git_repo.path / "ignored").write_text("ignored\n") + result = git_repo.update_repo(policy=SyncPolicy(dirty="discard")) + assert result.ok, result.errors + assert git_repo.get_revision() == head + assert (git_repo.path / "commit").read_text() == "local commit\n" + assert not (git_repo.path / "unknown").exists() + assert (git_repo.path / "ignored").read_text() == "ignored\n" + + +def test_preservation_phase_write_stops_update( + git_repo: GitSync, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unpublished updating phase cannot start the native update.""" + from libvcs._internal import preservation + + base = _preservation_update(git_repo) + (git_repo.path / "local").write_text("local\n") + phase = preservation.RecoveryStore.phase + + def fail_updating( + store: preservation.RecoveryStore, + token: t.Any, + record: t.Any, + value: str, + ) -> None: + if value == "updating": + message = "updating publication failed" + raise OSError(message) + phase(store, token, record, value) + + monkeypatch.setattr(preservation.RecoveryStore, "phase", fail_updating) + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is not None + assert result.update_state == "not-started" + assert git_repo.get_revision() == base + assert (git_repo.path / "local").read_text() == "local\n" + + +def test_preservation_clean_submodule_failure_is_reported( + git_repo: GitSync, mocker: MockerFixture +) -> None: + """Clean update reports native submodule errors without discarding checkout data.""" + mocker.patch.object( + git_repo.cmd.submodule, + "update", + side_effect=exc.CommandError( + output="submodule failure", returncode=1, cmd="git submodule update" + ), + ) + result = git_repo.update_repo() + assert not result.ok + assert result.errors[0].step == "submodule-update" + + +def test_preservation_ignored_obstruction_at_capture_base(git_repo: GitSync) -> None: + """Native stash cleanup cannot overwrite ignored obstructions at the old base.""" + directory = git_repo.path / "directory" + directory.mkdir() + (directory / "tracked").write_text("tracked\n") + git_repo.run(["add", "directory"]) + git_repo.run(["commit", "-m", "base directory"]) + base = _preservation_update(git_repo) + (directory / "tracked").unlink() + directory.rmdir() + (git_repo.path / ".git" / "info" / "exclude").write_text("directory\n") + directory.write_text("ignored obstruction\n") + result = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is None + assert git_repo.get_revision() == base + assert directory.read_text() == "ignored obstruction\n" + + +def test_preservation_damaged_record_retains_token(git_repo: GitSync) -> None: + """Damaged retained records block updates with their discoverable token.""" + _preservation_update(git_repo) + (git_repo.path / "local").write_text("local\n") + saved = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert saved.ok, saved.errors + assert saved.recovery is not None + (pathlib.Path(saved.recovery.location) / "operation.json").write_text("{") + result = git_repo.update_repo() + assert not result.ok + assert result.recovery == saved.recovery + assert result.update_state == "unknown" + + +@pytest.mark.parametrize( + "damage", ["phase-list", "phase-dict", "record-list", "result-errors"] +) +def test_preservation_schema_damage_returns_token( + git_repo: GitSync, + tmp_path: pathlib.Path, + damage: str, +) -> None: + """Every public recovery operation reports malformed JSON schemas with its token.""" + import json + + _preservation_update(git_repo) + (git_repo.path / "local").write_text("local\n") + saved = git_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert saved.ok, saved.errors + assert saved.recovery is not None + path = pathlib.Path(saved.recovery.location) / "operation.json" + record = json.loads(path.read_text()) + if damage == "record-list": + record = [] + elif damage == "result-errors": + record["result"]["errors"] = {"malformed": True} + else: + record["phase"] = [] if damage == "phase-list" else {} + path.write_text(json.dumps(record)) + before = git_repo.get_revision() + destination = tmp_path / "recovered" + for result in ( + git_repo.list_recoveries()[0], + git_repo.update_repo(), + git_repo.recover_changes(saved.recovery, destination=destination), + ): + assert not result.ok + assert result.recovery == saved.recovery + assert git_repo.get_revision() == before + assert not destination.exists() + assert git_repo.run( + ["rev-parse", f"refs/libvcs/preserve/{saved.recovery.id}"] + ).strip() + + +@pytest.mark.parametrize( + "depth,collision,unchanged", + [ + (1, True, False), + (2, True, False), + (1, False, False), + (2, False, False), + (1, True, True), + (2, True, True), + ], +) +def test_preservation_recursive_submodule_ignored_collision( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + git_commit_envvars: GitCommitEnvVars, + depth: int, + collision: bool, + unchanged: bool, +) -> None: + """Recursive updates guard ignored child paths, including unchanged parents.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + remotes = [tmp_path / f"remote-{index}" for index in range(depth + 1)] + for index, remote in enumerate(remotes): + run(["git", "init", str(remote)], env=git_commit_envvars) + if index == 0: + (remote / ".gitignore").write_text("build\ncache\n") + run(["git", "add", ".gitignore"], cwd=remote) + else: + run( + ["git", "submodule", "add", str(remotes[index - 1]), "child"], + cwd=remote, + ) + run(["git", "commit", "-m", "base"], cwd=remote, env=git_commit_envvars) + repo = GitSync(url=str(remotes[-1]), path=tmp_path / "checkout") + repo.obtain() + leaf = repo.path.joinpath(*(["child"] * depth)) + ignored = leaf / ("build" if collision else "cache") + ignored.write_text("valuable ignored local output\n") + leaf_base = run(["git", "rev-parse", "HEAD"], cwd=leaf) + (remotes[0] / "build").write_text("upstream\n") + run(["git", "add", "-f", "build"], cwd=remotes[0]) + run(["git", "commit", "-m", "track build"], cwd=remotes[0], env=git_commit_envvars) + for index in range(1, len(remotes)): + revision = run(["git", "rev-parse", "HEAD"], cwd=remotes[index - 1]).strip() + run(["git", "fetch"], cwd=remotes[index] / "child") + run(["git", "checkout", revision], cwd=remotes[index] / "child") + run( + ["git", "commit", "-am", "advance child"], + cwd=remotes[index], + env=git_commit_envvars, + ) + if unchanged: + repo.cmd.fetch(all=True, check_returncode=True) + revision = run(["git", "rev-parse", "HEAD"], cwd=remotes[-1]).strip() + repo.run(["checkout", "--detach", revision]) + repo.run(["config", "submodule.child.ignore", "all"]) + original = repo.get_position() + assert repo.is_dirty() is False + result = repo.update_repo( + policy=SyncPolicy(dirty="preserve") if depth == 2 else None + ) + assert result.ok is not collision, result.errors + assert ignored.read_text() == "valuable ignored local output\n" + if collision: + assert repo.get_position() == original + assert run(["git", "rev-parse", "HEAD"], cwd=leaf) == leaf_base + assert result.recovery is None + else: + assert (leaf / "build").read_text() == "upstream\n" + + +@pytest.mark.parametrize("collision", [False, True]) +def test_submodule_preflight_fetches_unadvertised_commit( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + git_commit_envvars: GitCommitEnvVars, + collision: bool, +) -> None: + """Fetch the pinned child commit before checking its ignored-file collisions.""" + monkeypatch.delenv("GIT_CONFIG", raising=False) + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + child_remote = tmp_path / "child-remote" + parent_remote = tmp_path / "parent-remote" + run(["git", "init", str(child_remote)], env=git_commit_envvars) + (child_remote / ".gitignore").write_text("build\n") + run(["git", "add", ".gitignore"], cwd=child_remote) + run(["git", "commit", "-m", "base"], cwd=child_remote, env=git_commit_envvars) + base = run(["git", "rev-parse", "HEAD"], cwd=child_remote).strip() + run(["git", "init", str(parent_remote)], env=git_commit_envvars) + run(["git", "submodule", "add", str(child_remote), "child"], cwd=parent_remote) + run(["git", "commit", "-am", "base"], cwd=parent_remote, env=git_commit_envvars) + repo = GitSync(url=str(parent_remote), path=tmp_path / "checkout") + repo.obtain() + repo.run(["config", "fetch.recurseSubmodules", "false"]) + child = repo.path / "child" + if collision: + (child / "build").write_text("valuable local output\n") + (child_remote / "build").write_text("upstream\n") + run(["git", "add", "-f", "build"], cwd=child_remote) + run( + ["git", "commit", "-m", "track build"], cwd=child_remote, env=git_commit_envvars + ) + desired = run(["git", "rev-parse", "HEAD"], cwd=child_remote).strip() + run( + ["git", "update-index", "--cacheinfo", f"160000,{desired},child"], + cwd=parent_remote, + ) + run(["git", "commit", "-m", "pin child"], cwd=parent_remote, env=git_commit_envvars) + run(["git", "reset", "--hard", base], cwd=child_remote) + original = repo.get_position() + result = repo.update_repo() + assert result.ok is not collision, result.errors + if collision: + assert "ignored path obstructs target" in result.errors[0].message + assert repo.get_position() == original + assert run(["git", "rev-parse", "HEAD"], cwd=child).strip() == base + assert (child / "build").read_text() == "valuable local output\n" + else: + assert run(["git", "rev-parse", "HEAD"], cwd=child).strip() == desired + assert (child / "build").read_text() == "upstream\n" diff --git a/tests/sync/test_hg.py b/tests/sync/test_hg.py index 74092010e..904f00af8 100644 --- a/tests/sync/test_hg.py +++ b/tests/sync/test_hg.py @@ -12,8 +12,8 @@ from libvcs._internal.run import run from libvcs._internal.shortcuts import create_project from libvcs.pytest_plugin import hg_remote_repo_single_commit_post_init -from libvcs.sync.base import SyncResult -from libvcs.sync.hg import HgSync +from libvcs.sync.base import SyncPolicy, SyncResult, SyncTarget +from libvcs.sync.hg import HgRemote, HgSync if t.TYPE_CHECKING: from libvcs.pytest_plugin import CreateRepoFn @@ -30,6 +30,205 @@ def set_vcs_hgconfig( return set_vcs_hgconfig +def test_hg_remotes_preserve_native_config( + hg_repo: HgSync, monkeypatch: pytest.MonkeyPatch +) -> None: + """Includes and comments survive atomic, idempotent fetch/push overrides.""" + included = hg_repo.path / ".hg" / "included.rc" + included.write_text("[paths]\nsecondary = https://example.com/original\n") + configuration = hg_repo.path / ".hg" / "hgrc" + original = configuration.read_bytes() + ( + b"\n# caller configuration\n%include included.rc\n[ui]\nverbose = false\n" + ) + configuration.write_bytes(original) + project = HgSync( + url=hg_repo.url, + path=hg_repo.path, + remotes={ + "secondary": HgRemote( + "secondary", "https://example.com/fetch", "ssh://example.com/push" + ) + }, + ) + project.set_remotes() + assert project.remotes()["secondary"].fetch_url == "https://example.com/original" + project.set_remotes(overwrite=True) + assert configuration.read_bytes().startswith(original) + assert included.read_text() == "[paths]\nsecondary = https://example.com/original\n" + assert project.remotes()["secondary"] == HgRemote( + "secondary", "https://example.com/fetch", "ssh://example.com/push" + ) + before = configuration.read_bytes(), configuration.stat().st_ino + project.set_remotes(overwrite=True) + assert (configuration.read_bytes(), configuration.stat().st_ino) == before + project = HgSync( + url=hg_repo.url, + path=hg_repo.path, + remotes={"secondary": {"fetch_url": "https://example.com/new"}}, + ) + project.set_remotes(overwrite=True) + assert project.remotes()["secondary"] == HgRemote( + "secondary", "https://example.com/new" + ) + original = configuration.read_bytes() + project = HgSync( + url=hg_repo.url, + path=hg_repo.path, + remotes={"secondary": "https://example.com/failure"}, + ) + replace = pathlib.Path.replace + + def fail_publish(source: pathlib.Path, destination: pathlib.Path) -> pathlib.Path: + if destination == configuration: + msg = "config publication failed" + raise OSError(msg) + return replace(source, destination) + + # Fail the atomic publish after the temporary file has been written. + monkeypatch.setattr(pathlib.Path, "replace", fail_publish) + with pytest.raises(OSError, match="config publication"): + project.set_remotes(overwrite=True) + assert configuration.read_bytes() == original + + +@pytest.mark.slow +@pytest.mark.parametrize("existing", [True, False]) +def test_hg_remote_selects_pull_without_changing_push( + hg_repo: HgSync, tmp_path: pathlib.Path, existing: bool +) -> None: + """A selected alias supplies history without replacing its push destination.""" + upstream = HgSync(url=str(hg_repo.path), path=tmp_path / "upstream") + upstream.obtain() + (upstream.path / "incoming").write_text("selected remote\n") + upstream.cmd.run(["add", "incoming"]) + upstream.cmd.run(["commit", "-m", "selected source"]) + revision = upstream.get_position().revision + project = HgSync( + url=hg_repo.url, + path=hg_repo.path if existing else tmp_path / "selected-clone", + remotes={ + "upstream": { + "fetch_url": str(upstream.path), + "push_url": "ssh://example.com/publish", + } + }, + ) + result = project.update_repo(target=SyncTarget(commit=revision, remote="upstream")) + assert result.ok, result.errors + assert project.get_position().revision == revision + assert (project.path / "incoming").read_text() == "selected remote\n" + assert project.remotes()["upstream"].push_url == "ssh://example.com/publish" + assert project.remotes()["default"].fetch_url == hg_repo.url + + +@pytest.mark.parametrize("value", ["bad\nurl", "", "bad\0url"]) +def test_hg_remotes_reject_config_injection(tmp_path: pathlib.Path, value: str) -> None: + """Remote URLs must fit one native config value before a checkout exists.""" + destination = tmp_path / "checkout" + with pytest.raises(ValueError): + HgSync(url="https://example.com/repo", path=destination, remotes={"a": value}) + assert not destination.exists() + + +@pytest.mark.parametrize("url", ["../other", "$HOME/other"]) +def test_hg_remote_native_paths_are_idempotent(hg_repo: HgSync, url: str) -> None: + """Native path expansion does not rewrite identical configured aliases.""" + project = HgSync(url=hg_repo.url, path=hg_repo.path, remotes={"other": url}) + project.set_remotes(overwrite=True) + configuration = project.path / ".hg" / "hgrc" + original = configuration.read_bytes(), configuration.stat().st_ino + project.set_remotes(overwrite=True) + assert (configuration.read_bytes(), configuration.stat().st_ino) == original + + +def test_hg_secondary_preserves_default_push(hg_repo: HgSync) -> None: + """An implicit default fetch alias does not reset an existing push destination.""" + configuration = hg_repo.path / ".hg" / "hgrc" + configuration.write_text( + configuration.read_text() + + ("\n[paths]\ndefault:pushurl = ssh://example.com/private-push\n") + ) + project = HgSync( + url=hg_repo.url, + path=hg_repo.path, + remotes={"secondary": "https://example.com/secondary"}, + ) + project.set_remotes(overwrite=True) + assert project.remotes()["default"].push_url == "ssh://example.com/private-push" + + +def test_hg_remotes_reject_symlink_config_even_without_changes(hg_repo: HgSync) -> None: + """Unchanged effective paths do not bypass the config ownership boundary.""" + configuration = hg_repo.path / ".hg" / "hgrc" + project = HgSync(url=hg_repo.remotes()["default"].fetch_url, path=hg_repo.path) + moved = configuration.with_name("external.rc") + configuration.rename(moved) + configuration.symlink_to(moved) + before = moved.read_bytes() + with pytest.raises(ValueError, match="symlink"): + project.set_remotes(overwrite=True) + assert moved.read_bytes() == before + + +def test_hg_position_reports_active_bookmark(hg_repo: HgSync) -> None: + """An active bookmark takes precedence over the named branch.""" + revision = hg_repo.cmd.run(["log", "-r", ".", "-T", "{node}"]).strip() + position = hg_repo.get_position() + assert (position.ref_kind, position.ref_name) == ("branch", "default") + assert position.revision == revision + assert position.follows + + hg_repo.cmd.run(["branch", "next"], check_returncode=True) + assert hg_repo.get_position().ref_name == "next" + + hg_repo.cmd.run(["bookmark", "develop"], check_returncode=True) + position = hg_repo.get_position() + assert (position.ref_kind, position.ref_name) == ("bookmark", "develop") + assert position.revision == revision + assert position.follows + + +@pytest.mark.slow +@pytest.mark.parametrize("drift", ["follow", "keep", "warn"]) +def test_hg_initial_method_target_overrides_constructor_revision( + hg_repo: HgSync, tmp_path: pathlib.Path, drift: t.Literal["follow", "keep", "warn"] +) -> None: + """Native clone and pull establish the target before drift policy applies.""" + expected = hg_repo.get_position().revision + (hg_repo.path / "later").write_text("later\n") + hg_repo.cmd.run(["add", "later"]) + hg_repo.cmd.run(["commit", "-m", "later"]) + project = HgSync(url=hg_repo.path.as_uri(), path=tmp_path / "clone", rev="missing") + result = project.update_repo( + target=SyncTarget(rev="0"), policy=SyncPolicy(drift=drift) + ) + assert result.ok, result.errors + assert project.get_position().revision == expected + + +@pytest.mark.slow +def test_hg_initial_target_skips_unrelated_subrepository( + hg_repo: HgSync, tmp_path: pathlib.Path +) -> None: + """An older checkout needs no subrepository present only at the remote tip.""" + expected = hg_repo.get_position().revision + remote = tmp_path / "child-remote" + run(["hg", "init", str(remote)]) + (remote / "file").write_text("child\n") + run(["hg", "add", "file"], cwd=remote) + run(["hg", "commit", "-m", "child"], cwd=remote) + run(["hg", "clone", str(remote), str(hg_repo.path / "child")]) + (hg_repo.path / ".hgsub").write_text(f"child = {remote}\n") + hg_repo.cmd.run(["add", ".hgsub"]) + hg_repo.cmd.run(["commit", "-m", "subrepository"]) + shutil.rmtree(remote) + project = HgSync(url=str(hg_repo.path), path=tmp_path / "older") + result = project.update_repo(target=SyncTarget(rev="0")) + assert result.ok, result.errors + assert project.get_position().revision == expected + assert not (project.path / ".hgsub").exists() + + def test_hg_sync( tmp_path: pathlib.Path, projects_path: pathlib.Path, @@ -137,3 +336,588 @@ def test_update_repo_pull_failure_returns_sync_result( assert len(result.errors) > 0 assert result.errors[0].step == "pull" assert isinstance(result.errors[0].exception, exc.CommandError) + + +@pytest.mark.parametrize("prefix,revision", [("hg+", None), ("", "0")]) +def test_obtain_transport_and_revision( + hg_repo: HgSync, + tmp_path: pathlib.Path, + prefix: str, + revision: str | None, +) -> None: + """Clone strips adapter prefixes and checks out the requested initial revision.""" + base = hg_repo.cmd.run(["log", "-r", "0", "-T", "{node}"]) + (hg_repo.path / "later").write_text("later\n") + hg_repo.cmd.run(["add", "later"]) + hg_repo.cmd.run(["commit", "-m", "later"]) + repo = HgSync( + url=prefix + hg_repo.path.as_uri(), path=tmp_path / "clone", rev=revision + ) + repo.obtain() + expected = base if revision else hg_repo.get_position().revision + assert repo.get_position().revision == expected + + +def _hg_advance(repo: HgSync) -> tuple[str, str]: + base = repo.get_position().revision + (repo.path / "upstream").write_text("upstream\n") + repo.cmd.run(["add", "upstream"]) + repo.cmd.run(["commit", "-m", "upstream"]) + target = repo.get_position().revision + repo.cmd.run(["update", "--rev", base]) + return base, target + + +def test_hg_preservation_default_abort(hg_repo: HgSync) -> None: + """Dirty abort never updates the parent or removes unknown files.""" + base, _ = _hg_advance(hg_repo) + (hg_repo.path / "unknown").write_text("unknown\n") + configuration = hg_repo.path / ".hg" / "hgrc" + before = configuration.read_bytes() + project = HgSync( + url=hg_repo.url, + path=hg_repo.path, + remotes={"secondary": "https://example.com/secondary"}, + ) + result = project.update_repo() + assert not result.ok + assert hg_repo.get_position().revision == base + assert (hg_repo.path / "unknown").read_text() == "unknown\n" + assert configuration.read_bytes() == before + + +@pytest.mark.slow +def test_hg_preservation_offline_exact_status( + hg_repo: HgSync, + tmp_path: pathlib.Path, +) -> None: + """Independent recovery retains schedules, missing paths, and unknown files.""" + for name in ("removed", "missing"): + (hg_repo.path / name).write_text(name + "\n") + hg_repo.cmd.run(["add", "removed", "missing"]) + hg_repo.cmd.run(["commit", "-m", "base files"]) + base, target = _hg_advance(hg_repo) + hg_repo.cmd.run(["remove", "removed"]) + (hg_repo.path / "missing").unlink() + (hg_repo.path / "added").write_text("added\n") + hg_repo.cmd.run(["add", "added"]) + (hg_repo.path / "unknown").write_text("unknown\n") + (hg_repo.path / "caller.orig").write_text("caller backup\n") + before = hg_repo.cmd.run(["status", "-0"]) + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert result.ok, result.errors + assert result.recovery is not None + assert result.preservation_state == "restored" + assert hg_repo.cmd.run(["status", "-0"]) == before + (hg_repo.path / ".hg" / "hgrc").write_text( + "[paths]\ndefault = /nonexistent-remote\n" + ) + destination = tmp_path / "recovered" + recovered = hg_repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + assert run(["hg", "status", "-0"], cwd=destination) == before + assert run(["hg", "log", "-r", ".", "-T", "{node}"], cwd=destination) == base + assert (destination / "caller.orig").read_text() == "caller backup\n" + hg_repo.release_changes(result.recovery) + assert hg_repo.list_recoveries() == () + + +@pytest.mark.slow +@pytest.mark.parametrize( + "state", + ["missing-added", "missing-copy", "removed-present", "removed-capture-failure"], +) +def test_hg_preservation_overlapping_schedule_and_files( + hg_repo: HgSync, + tmp_path: pathlib.Path, + state: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Retain schedules hidden by status and files hidden by removal schedules.""" + (hg_repo.path / "tracked").write_bytes(b"base\n") + hg_repo.cmd.run(["add", "tracked"]) + hg_repo.cmd.run(["commit", "-m", "tracked base"]) + _, target = _hg_advance(hg_repo) + if state.startswith("removed"): + hg_repo.cmd.run(["remove", "tracked"]) + (hg_repo.path / "tracked").write_bytes(b"valuable\x00recreated\n") + else: + if state == "missing-copy": + hg_repo.cmd.run(["copy", "tracked", "added"]) + else: + (hg_repo.path / "added").write_text("added\n") + hg_repo.cmd.run(["add", "added"]) + (hg_repo.path / "added").unlink() + (hg_repo.path / "unknown").write_text("unknown\n") + if state == "removed-capture-failure": + (hg_repo.path / "unknown").write_text("unknown\n") + native_run = hg_repo.cmd.run + + def fail_shelve(args: t.Any, **kwargs: t.Any) -> str: + if "shelve" in args: + raise exc.CommandError( + cmd=args, returncode=1, output="native shelve did not start" + ) + return native_run(args, **kwargs) + + monkeypatch.setattr(hg_repo.cmd, "run", fail_shelve) + before = hg_repo.cmd.run(["status", "--copies", "-Tjson"]) + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + if state == "removed-capture-failure": + assert not result.ok + assert (hg_repo.path / "tracked").read_bytes() == b"valuable\x00recreated\n" + assert hg_repo.cmd.run(["status", "--copies", "-Tjson"]) == before + return + assert result.ok, result.errors + assert result.recovery is not None + assert hg_repo.cmd.run(["status", "--copies", "-Tjson"]) == before + destination = tmp_path / "recovered" + restored = hg_repo.recover_changes(result.recovery, destination=destination) + assert restored.ok, restored.errors + assert run(["hg", "status", "--copies", "-Tjson"], cwd=destination) == before + if state == "removed-present": + for root in (hg_repo.path, destination): + assert (root / "tracked").read_bytes() == b"valuable\x00recreated\n" + else: + assert not (destination / "added").exists() + assert (destination / "unknown").read_text() == "unknown\n" + + +@pytest.mark.slow +@pytest.mark.parametrize("kind", ["disjoint", "text", "binary", "unknown", "missing"]) +def test_hg_preservation_native_conflict_recovery( + hg_repo: HgSync, + tmp_path: pathlib.Path, + kind: str, +) -> None: + """Native shelf conflict matrices include independent original-state recovery.""" + shared = hg_repo.path / "shared" + if kind != "unknown": + shared.write_bytes( + b"base\0binary" + if kind == "binary" + else b"first\n" + b"middle\n" * 12 + b"last\n" + ) + hg_repo.cmd.run(["add", "shared"]) + hg_repo.cmd.run(["commit", "-m", "base"]) + base = hg_repo.get_position().revision + shared.write_bytes( + b"upstream\0binary" + if kind == "binary" + else b"upstream\n" + b"middle\n" * 12 + b"last\n" + ) + if kind == "unknown": + hg_repo.cmd.run(["add", "shared"]) + hg_repo.cmd.run(["commit", "-m", "upstream"]) + target = hg_repo.get_position().revision + hg_repo.cmd.run(["update", "--rev", base]) + if kind == "missing": + shared.unlink() + else: + shared.write_bytes( + b"local\0binary" + if kind == "binary" + else b"first\n" + b"middle\n" * 12 + b"local\n" + if kind == "disjoint" + else b"local\n" + ) + (hg_repo.path / "shared.orig").write_text("caller backup\n") + before = hg_repo.cmd.run(["status", "-0"]) + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert result.ok is (kind == "disjoint"), result.errors + if kind != "disjoint": + assert result.preservation_state == "conflicted", result.errors + assert result.conflicts + assert result.recovery is not None + assert (hg_repo.path / "shared.orig").read_text() == "caller backup\n" + destination = tmp_path / "recovered" + recovered = hg_repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + assert run(["hg", "status", "-0"], cwd=destination) == before + assert (destination / "shared.orig").read_text() == "caller backup\n" + if kind == "missing": + assert not (destination / "shared").exists() + elif kind == "disjoint": + assert shared.read_bytes().startswith(b"upstream\n") + assert shared.read_bytes().endswith(b"local\n") + else: + assert (destination / "shared").read_bytes() == ( + b"local\0binary" if kind == "binary" else b"local\n" + ) + + +@pytest.mark.slow +@pytest.mark.parametrize("identity", ["bookmark", "pending-branch", "missing-only"]) +def test_hg_preservation_original_metadata( + hg_repo: HgSync, + tmp_path: pathlib.Path, + identity: str, +) -> None: + """Original metadata and missing-only state survive separate native recovery.""" + (hg_repo.path / "tracked").write_text("tracked\n") + hg_repo.cmd.run(["add", "tracked"]) + hg_repo.cmd.run(["commit", "-m", "base"]) + _, target = _hg_advance(hg_repo) + if identity == "bookmark": + hg_repo.cmd.run(["bookmark", "local-bookmark"]) + (hg_repo.path / "tracked").write_text("local\n") + elif identity == "pending-branch": + hg_repo.cmd.run(["branch", "pending-branch"]) + else: + (hg_repo.path / "tracked").unlink() + original = hg_repo.get_position() + before = hg_repo.cmd.run(["status", "-0"]) + assert hg_repo.is_dirty() + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert result.ok, result.errors + assert result.recovery is not None + destination = tmp_path / "recovered" + recovered = hg_repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + repo = HgSync(url=hg_repo.url, path=destination) + assert repo.get_position() == original + assert repo.cmd.run(["status", "-0"]) == before + + +@pytest.mark.slow +@pytest.mark.parametrize("collision", [True, False]) +def test_hg_preservation_ignored_collision(hg_repo: HgSync, collision: bool) -> None: + """Ignored output blocks only target writes that overlap it.""" + base, target = _hg_advance(hg_repo) + (hg_repo.path / ".hgignore").write_text("syntax: glob\nupstream\ncache\n") + hg_repo.cmd.run(["add", ".hgignore"]) + hg_repo.cmd.run(["commit", "-m", "ignore"]) + base = hg_repo.get_position().revision + output = hg_repo.path / ("upstream" if collision else "cache") + output.write_text("ignored\n") + result = hg_repo.update_repo(target=SyncTarget(commit=target)) + assert result.ok is not collision, result.errors + assert output.read_text() == "ignored\n" + if collision: + assert hg_repo.get_position().revision == base + assert result.recovery is None + + +@pytest.mark.slow +@pytest.mark.parametrize( + "fault", ["capture", "update", "restore", "inspection", "publication"] +) +def test_hg_preservation_retains_token_after_fault( + hg_repo: HgSync, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + fault: str, +) -> None: + """Native failure stages retain their first error and independent shelf.""" + from libvcs._internal import preservation + + _, target = _hg_advance(hg_repo) + (hg_repo.path / "unknown").write_text("unknown\n") + real_run = hg_repo.cmd.run + + def fail_command(args: t.Any, **kwargs: t.Any) -> str: + if (fault == "update" and "update" in args) or ( + fault == "restore" and "unshelve" in args + ): + raise exc.CommandError(cmd=args, returncode=1, output=fault) + output = real_run(args, **kwargs) + if fault == "capture" and "shelve" in args: + raise exc.CommandError(cmd=args, returncode=1, output=fault) + return output + + def fail_inspection() -> tuple[t.Any, ...]: + raise exc.CommandError(cmd="hg resolve", returncode=1, output="inspection") + + def fail_finish(*args: t.Any, **kwargs: t.Any) -> None: + message = "publication" + raise OSError(message) + + with monkeypatch.context() as patch: + patch.setattr(hg_repo.cmd, "run", fail_command) + if fault == "inspection": + patch.setattr(hg_repo, "_conflicts", fail_inspection) + if fault == "publication": + patch.setattr(preservation.RecoveryStore, "finish", fail_finish) + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert not result.ok + assert result.recovery is not None + assert result.errors[0].step == fault + assert hg_repo.list_recoveries()[0].recovery == result.recovery + if fault == "inspection": + assert result.preservation_state == "unknown" + if fault == "update": + shutil.rmtree(hg_repo.path / ".hg" / "shelved") + recovered = hg_repo.recover_changes( + result.recovery, destination=tmp_path / "recovered" + ) + assert recovered.ok, recovered.errors + assert (tmp_path / "recovered" / "unknown").read_text() == "unknown\n" + + +@pytest.mark.slow +def test_hg_preservation_interrupted_capture( + hg_repo: HgSync, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """Interrupted shelf creation remains discoverable without resuming update.""" + _, target = _hg_advance(hg_repo) + (hg_repo.path / "unknown").write_text("unknown\n") + real_run = hg_repo.cmd.run + + def interrupt(args: t.Any, **kwargs: t.Any) -> str: + output = real_run(args, **kwargs) + if "shelve" in args: + raise KeyboardInterrupt + return output + + with monkeypatch.context() as patch: + patch.setattr(hg_repo.cmd, "run", interrupt) + with pytest.raises(KeyboardInterrupt): + hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + interrupted = hg_repo.list_recoveries()[0] + assert not interrupted.ok + assert interrupted.recovery is not None + assert hg_repo.update_repo().recovery == interrupted.recovery + result = hg_repo.recover_changes( + interrupted.recovery, destination=tmp_path / "recovered" + ) + assert result.ok, result.errors + assert (tmp_path / "recovered" / "unknown").read_text() == "unknown\n" + + +@pytest.mark.parametrize( + "native", ["wlock", "shelvedstate", "rebasestate", "subrepo", "nested"] +) +def test_hg_preservation_native_preconditions(hg_repo: HgSync, native: str) -> None: + """Native busy and unsupported nested scopes fail before pulling or capturing.""" + if native == "wlock": + (hg_repo.path / ".hg" / native).symlink_to("busy-owner:12345") + elif native == "subrepo": + (hg_repo.path / ".hgsub").write_text("child = ../child\n") + elif native == "nested": + (hg_repo.path / "child" / ".hg").mkdir(parents=True) + else: + (hg_repo.path / ".hg" / native).write_text("busy\n") + result = hg_repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.errors[0].step == "precondition" + assert result.recovery is None + + +@pytest.mark.slow +@pytest.mark.parametrize("drift", ["keep", "warn"]) +def test_hg_preservation_drift_keeps_dirty_position( + hg_repo: HgSync, + drift: t.Literal["keep", "warn"], + caplog: pytest.LogCaptureFixture, +) -> None: + """Keep and warn compare resolved nodes and leave dirty checkouts untouched.""" + base, target = _hg_advance(hg_repo) + (hg_repo.path / "unknown").write_text("local\n") + configuration = hg_repo.path / ".hg" / "hgrc" + before = configuration.read_bytes() + project = HgSync( + url=hg_repo.url, + path=hg_repo.path, + remotes={"secondary": "https://example.com/secondary"}, + ) + result = project.update_repo( + target=SyncTarget(commit=target, remote="secondary"), + policy=SyncPolicy(drift=drift), + ) + assert result.ok, result.errors + assert result.recovery is None + assert hg_repo.get_position().revision == base + assert (hg_repo.path / "unknown").read_text() == "local\n" + assert configuration.read_bytes() == before + warnings = [record for record in caplog.records if record.name == "libvcs.sync.hg"] + assert bool(warnings) is (drift == "warn") + if warnings: + assert warnings[0].__dict__["vcs_type"] == "hg" + + +@pytest.mark.slow +def test_hg_preservation_release_owns_only_named_shelf(hg_repo: HgSync) -> None: + """Release preserves a caller shelf and detects changed owned material.""" + _, target = _hg_advance(hg_repo) + (hg_repo.path / "caller").write_text("caller\n") + hg_repo.cmd.run(["shelve", "--unknown", "--name", "caller", "--message", "caller"]) + caller = (hg_repo.path / ".hg" / "shelved" / "caller.patch").read_bytes() + (hg_repo.path / "unknown").write_text("local\n") + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert result.ok, result.errors + assert result.recovery is not None + owned = hg_repo.path / ".hg" / "shelved" / f"libvcs-{result.recovery.id}.patch" + original = owned.read_bytes() + owned.write_bytes(original + b"changed\n") + with pytest.raises(ValueError, match="ownership"): + hg_repo.release_changes(result.recovery) + assert pathlib.Path(result.recovery.location).exists() + owned.write_bytes(original) + hg_repo.release_changes(result.recovery) + assert hg_repo.list_recoveries() == () + assert (hg_repo.path / ".hg" / "shelved" / "caller.patch").read_bytes() == caller + + +@pytest.mark.slow +@pytest.mark.parametrize("damage", ["phase", "status", "material"]) +def test_hg_preservation_damaged_records_retain_token( + hg_repo: HgSync, + tmp_path: pathlib.Path, + damage: str, +) -> None: + """Damaged schema or sealed artifacts never become successful recovery.""" + import json + + _, target = _hg_advance(hg_repo) + (hg_repo.path / "unknown").write_text("local\n") + saved = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert saved.ok, saved.errors + assert saved.recovery is not None + record_path = pathlib.Path(saved.recovery.location) / "operation.json" + if damage == "material": + patch = record_path.parent / "material" / f"libvcs-{saved.recovery.id}.patch" + patch.write_bytes(b"corrupt") + else: + record = json.loads(record_path.read_text()) + if damage == "phase": + record["phase"] = [] + else: + record["original"]["status"] = [] + record_path.write_text(json.dumps(record)) + found = hg_repo.list_recoveries()[0] + assert not found.ok + assert found.recovery == saved.recovery + destination = tmp_path / "recovered" + result = hg_repo.recover_changes(saved.recovery, destination=destination) + assert not result.ok + assert result.recovery == saved.recovery + assert not destination.exists() + if damage != "material": + result = hg_repo.update_repo() + assert not result.ok + assert result.recovery == saved.recovery + + +@pytest.mark.slow +def test_hg_target_equal_bookmark_follow_and_keep(hg_repo: HgSync) -> None: + """Equal nodes are not drift; follow activates an explicitly selected bookmark.""" + hg_repo.cmd.run(["bookmark", "--inactive", "alias"]) + original = hg_repo.get_position() + target = SyncTarget(branch="alias") + assert hg_repo.resolve_target(target).revision == original.revision + assert hg_repo.resolve_target(SyncTarget(rev="alias")).ref_kind == "bookmark" + result = hg_repo.update_repo(target=target, policy=SyncPolicy(drift="keep")) + assert result.ok, result.errors + assert hg_repo.get_position() == original + result = hg_repo.update_repo(target=target) + assert result.ok, result.errors + assert hg_repo.get_position().ref_name == "alias" + assert hg_repo.get_position().ref_kind == "bookmark" + + +@pytest.mark.slow +def test_hg_preservation_phase_failure_stops_mutation( + hg_repo: HgSync, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed phase write retains the shelf without starting another mutation.""" + from libvcs._internal import preservation + + base, target = _hg_advance(hg_repo) + (hg_repo.path / "unknown").write_text("local\n") + real_phase = preservation.RecoveryStore.phase + real_run = hg_repo.cmd.run + mutated = [] + + def fail_phase( + store: preservation.RecoveryStore, + token: t.Any, + record: t.Any, + phase: str, + ) -> None: + if phase == "updating": + message = "phase publication failed" + raise OSError(message) + real_phase(store, token, record, phase) + + def record_run(args: t.Any, **kwargs: t.Any) -> str: + if "update" in args or "unshelve" in args: + mutated.append(args) + return real_run(args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(preservation.RecoveryStore, "phase", fail_phase) + patch.setattr(hg_repo.cmd, "run", record_run) + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="preserve") + ) + assert not result.ok + assert result.recovery is not None + assert not mutated + assert hg_repo.get_position().revision == base + assert not (hg_repo.path / "unknown").exists() + assert hg_repo.list_recoveries()[0].recovery == result.recovery + + +@pytest.mark.slow +def test_hg_discard_preserves_ignored_output(hg_repo: HgSync) -> None: + """Explicit discard removes ordinary dirt while retaining ignored output.""" + (hg_repo.path / ".hgignore").write_text("syntax: glob\ncache\n") + (hg_repo.path / "tracked").write_text("base\n") + hg_repo.cmd.run(["add", ".hgignore", "tracked"]) + hg_repo.cmd.run(["commit", "-m", "tracked base"]) + _, target = _hg_advance(hg_repo) + (hg_repo.path / "tracked").write_text("local\n") + (hg_repo.path / "added").write_text("added\n") + hg_repo.cmd.run(["add", "added"]) + (hg_repo.path / "unknown").write_text("unknown\n") + (hg_repo.path / "cache").write_text("ignored\n") + result = hg_repo.update_repo( + target=SyncTarget(commit=target), policy=SyncPolicy(dirty="discard") + ) + assert result.ok, result.errors + assert result.recovery is None + assert hg_repo.get_position().revision == target + assert hg_repo.cmd.run(["status", "-0"]) == "" + assert (hg_repo.path / "tracked").read_text() == "base\n" + assert not (hg_repo.path / "added").exists() + assert not (hg_repo.path / "unknown").exists() + assert (hg_repo.path / "cache").read_text() == "ignored\n" + + +@pytest.mark.slow +def test_hg_target_aliases_are_metadata_only( + hg_repo: HgSync, caplog: pytest.LogCaptureFixture +) -> None: + """Named branches, tags, and revisions at the same node cause no drift.""" + hg_repo.cmd.run(["tag", "--local", "alias"]) + original = hg_repo.get_position() + for target in ( + SyncTarget(branch="default"), + SyncTarget(tag="alias"), + SyncTarget(commit=original.revision), + SyncTarget(rev="alias"), + ): + assert hg_repo.resolve_target(target).revision == original.revision + result = hg_repo.update_repo(target=target, policy=SyncPolicy(drift="warn")) + assert result.ok, result.errors + assert result.update_state == "not-started" + assert hg_repo.get_position() == original + assert not [record for record in caplog.records if record.name == "libvcs.sync.hg"] diff --git a/tests/sync/test_options.py b/tests/sync/test_options.py new file mode 100644 index 000000000..0f21df3b9 --- /dev/null +++ b/tests/sync/test_options.py @@ -0,0 +1,390 @@ +"""Tests for typed backend synchronization options.""" + +from __future__ import annotations + +import dataclasses +import inspect +import pathlib +import typing as t + +import pytest + +from libvcs import ( + BaseSync, + GitOptions, + GitSync, + HgOptions, + HgSync, + SvnOptions, + SvnSync, + SyncTarget, +) +from libvcs._internal.shortcuts import create_project +from libvcs.cmd.git_filter import BlobNone, filter_specs + +if t.TYPE_CHECKING: + from pytest_mock import MockerFixture + + +@pytest.mark.parametrize( + ("options_type", "field_names"), + [ + (GitOptions, ["depth", "filter", "tls_verify"]), + (HgOptions, ["ssh", "remote_cmd", "pull", "stream", "tls_verify"]), + ( + SvnOptions, + [ + "username", + "password", + "depth", + "trust_server_cert", + "ignore_externals", + ], + ), + ], +) +def test_backend_options_expose_exact_config_fields( + options_type: type[GitOptions | HgOptions | SvnOptions], + field_names: list[str], +) -> None: + """Dataclass introspection exposes only user-configurable backend fields.""" + assert [field.name for field in dataclasses.fields(options_type)] == field_names + + +def test_backend_sync_classes_publish_their_options_type() -> None: + """Generic callers can discover each backend's options dataclass.""" + assert GitSync.options_type is GitOptions + assert HgSync.options_type is HgOptions + assert SvnSync.options_type is SvnOptions + + +def test_backend_options_have_secure_neutral_defaults() -> None: + """Defaults preserve full clones and verify Git and Mercurial TLS.""" + assert GitOptions() == GitOptions(depth=None, filter=None, tls_verify=True) + assert HgOptions() == HgOptions( + ssh=None, + remote_cmd=None, + pull=False, + stream=False, + tls_verify=True, + ) + assert SvnOptions() == SvnOptions( + username=None, + password=None, + depth=None, + trust_server_cert=False, + ignore_externals=False, + ) + + +def test_backend_options_are_frozen_and_hide_svn_password() -> None: + """Options are immutable, and repr never reveals a Subversion password.""" + options = SvnOptions(password="secret") + + with pytest.raises(dataclasses.FrozenInstanceError): + options.depth = "files" # type: ignore[misc] + + assert "secret" not in repr(options) + + +def test_git_options_validate_depth_and_snapshot_filter() -> None: + """Git options reject invalid depth and detach filter config from callers.""" + filters: list[object] = [BlobNone(), {"kind": "tree", "depth": 2}] + options = GitOptions(depth=2, filter=filters) + filters.append("object:type=commit") + + assert options.filter == ("blob:none", "tree:2") + with pytest.raises(ValueError, match="depth"): + GitOptions(depth=True) + with pytest.raises(ValueError, match="depth"): + GitOptions(depth=0) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: GitOptions(tls_verify=t.cast(t.Any, 1)), + lambda: HgOptions(ssh=t.cast(t.Any, False)), + lambda: HgOptions(pull=t.cast(t.Any, 1)), + lambda: SvnOptions(username=t.cast(t.Any, False)), + lambda: SvnOptions(depth=t.cast(t.Any, "children")), + lambda: SvnOptions(ignore_externals=t.cast(t.Any, 1)), + ], +) +def test_backend_options_reject_wrong_field_types( + factory: t.Callable[[], object], +) -> None: + """Options reject values outside their documented runtime types.""" + with pytest.raises((TypeError, ValueError)): + factory() + + +def test_git_options_normalization_preserves_filter_nesting_limit() -> None: + """Canonical repeated flags retain the nesting accepted at construction.""" + spec = "combine:" * 32 + "blob:none" + options = GitOptions(filter=spec) + + assert filter_specs(options.filter) == filter_specs(spec) + with pytest.raises(ValueError, match="nesting"): + GitOptions(filter="combine:" + spec) + + +@pytest.mark.parametrize( + ("options_type", "field"), + [ + (HgOptions, "ssh"), + (HgOptions, "remote_cmd"), + (SvnOptions, "username"), + (SvnOptions, "password"), + ], +) +def test_backend_options_reject_nul_before_command_creation( + options_type: type[HgOptions | SvnOptions], + field: str, +) -> None: + """Strings destined for native argv cannot contain embedded NUL bytes.""" + with pytest.raises(ValueError, match=field): + options_type(**{field: "value\0suffix"}) # type: ignore[arg-type] + + +def test_sync_constructors_reject_unknown_and_wrong_backend_options( + tmp_path: pathlib.Path, +) -> None: + """Explicit constructors reject misspellings and backend mismatches.""" + with pytest.raises(TypeError, match="unexpected"): + GitSync(url="file:///unused", path=tmp_path, tls_verfy=False) # type: ignore[call-arg] + with pytest.raises(TypeError, match="unexpected"): + BaseSync(url="file:///unused", path=tmp_path, revision="main") # type: ignore[call-arg] + with pytest.raises(TypeError, match="unexpected"): + GitOptions(dept=1) # type: ignore[call-arg] + with pytest.raises(TypeError, match="GitOptions"): + GitSync(url="file:///unused", path=tmp_path, options=HgOptions()) # type: ignore[arg-type] + with pytest.raises(TypeError, match="unexpected"): + create_project( # type: ignore[call-overload] + url="file:///unused", + path=tmp_path, + vcs="git", + tls_verfy=False, + ) + + assert not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in inspect.signature(GitSync).parameters.values() + ) + + +def test_create_project_forwards_backend_options(tmp_path: pathlib.Path) -> None: + """The backend-neutral shortcut preserves the selected typed options.""" + options = HgOptions(stream=True) + + repo = create_project( + url="https://example.com/repo", + path=tmp_path, + vcs="hg", + options=options, + ) + + assert repo.options is options + with pytest.raises(TypeError, match="HgOptions"): + create_project( # type: ignore[call-overload] + url="https://example.com/repo", + path=tmp_path, + vcs="hg", + options=GitOptions(), + ) + + +def test_create_project_forwards_hg_remotes( + tmp_path: pathlib.Path, hg_repo: HgSync +) -> None: + """The factory applies Mercurial fetch and push destinations separately.""" + from libvcs import HgRemote + + remote = HgRemote( + "upstream", hg_repo.path.as_uri(), (tmp_path / "push-only").as_uri() + ) + repo = create_project( + url=hg_repo.url, + path=tmp_path / "checkout", + vcs="hg", + remotes={"upstream": remote}, + ) + assert repo.update_repo().ok + assert repo.remotes()["upstream"] == remote + + +def test_git_disabled_tls_option_runs_native_clone_and_fetch( + tmp_path: pathlib.Path, + git_remote_repo: pathlib.Path, +) -> None: + """Clone, fetch, and submodule commands accept the TLS override as argv.""" + repo = GitSync( + url=git_remote_repo.as_uri(), + path=tmp_path / "checkout", + options=GitOptions(tls_verify=False), + ) + repo.obtain() + revision = repo.get_position().revision + result = repo.update_repo() + + assert result.ok + assert repo.get_position().revision == revision + + +def test_git_options_forward_clone_filter_depth_and_tls_polarity( + tmp_path: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Git clone receives typed depth, filter, and disabled TLS verification.""" + repo = GitSync( + url="https://example.com/repo.git", + path=tmp_path / "checkout", + options=GitOptions(depth=3, filter=BlobNone(), tls_verify=False), + ) + clone = mocker.patch.object(repo.cmd, "clone", return_value="") + mocker.patch.object(repo.cmd.submodule, "init", return_value="") + update = mocker.patch.object(repo.cmd.submodule, "update", return_value="") + mocker.patch.object(repo, "set_remotes") + + repo.obtain() + + assert clone.call_args.kwargs["depth"] == 3 + assert clone.call_args.kwargs["_filter"] == ("blob:none",) + assert clone.call_args.kwargs["config"] == {"http.sslVerify": False} + assert update.call_args.kwargs["_filter"] == ("blob:none",) + assert update.call_args.kwargs["config"] == {"http.sslVerify": False} + + +def test_git_tls_option_applies_to_existing_checkout_network_operations( + git_repo: GitSync, + mocker: MockerFixture, +) -> None: + """Disabled verification reaches fetch and clean submodule updates.""" + git_repo.options = GitOptions(tls_verify=False) + commands = mocker.spy(git_repo.cmd, "run") + submodules = mocker.spy(git_repo.cmd.submodule, "update") + + result = git_repo.update_repo() + + assert result.ok, result.errors + fetch = next(call for call in commands.call_args_list if call.args[0][0] == "fetch") + assert fetch.kwargs["config"] == {"http.sslVerify": False} + assert "--all" in fetch.args[0] + assert submodules.call_args.kwargs["config"] == {"http.sslVerify": False} + + +def test_git_target_fetches_named_remote(git_repo: GitSync) -> None: + """A configured target can resolve a remote other than the default origin.""" + original = git_repo.get_position() + git_repo.cmd.run( + ["remote", "add", "secondary", git_repo.url], check_returncode=True + ) + + result = git_repo.update_repo( + target=SyncTarget(branch=original.ref_name, remote="secondary") + ) + + assert result.ok, result.errors + assert ( + git_repo.cmd.run( + ["rev-parse", f"refs/remotes/secondary/{original.ref_name}"], + check_returncode=True, + ).strip() + == original.revision + ) + + +def test_hg_options_forward_clone_and_network_update( + tmp_path: pathlib.Path, + hg_repo: HgSync, + mocker: MockerFixture, +) -> None: + """Mercurial transport options reach clone and pull operations.""" + options = HgOptions( + ssh="ssh -i key", + remote_cmd="hg-custom", + pull=True, + stream=True, + tls_verify=False, + ) + repo = HgSync(url="https://example.com/repo", path=tmp_path, options=options) + clone = mocker.patch.object(repo.cmd, "clone", return_value="") + mocker.patch.object(repo.cmd, "update", return_value="") + + repo.obtain() + + assert clone.call_args.kwargs == { + "no_update": True, + "quiet": True, + "url": repo.url, + "ssh": "ssh -i key", + "remote_cmd": "hg-custom", + "pull": True, + "stream": True, + "insecure": True, + "check_returncode": True, + } + + hg_repo.options = options + pull = mocker.patch.object(hg_repo.cmd, "pull", return_value="") + result = hg_repo.update_repo() + assert result.ok, result.errors + assert pull.call_args.kwargs == { + "source": None, + "update": False, + "ssh": "ssh -i key", + "remote_cmd": "hg-custom", + "insecure": True, + "check_returncode": True, + } + + +def test_svn_options_forward_checkout_and_update( + tmp_path: pathlib.Path, + svn_remote_repo: pathlib.Path, + mocker: MockerFixture, +) -> None: + """Subversion checkout and network update receive their typed options.""" + options = SvnOptions( + username="reader", + password="secret", + depth="files", + trust_server_cert=True, + ignore_externals=True, + ) + repo = SvnSync(url="https://example.com/repo", path=tmp_path, options=options) + checkout = mocker.patch.object(repo.cmd, "checkout", return_value="") + + repo.obtain() + + assert checkout.call_args.kwargs == { + "url": repo.url, + "revision": None, + "username": "reader", + "password": "secret", + "depth": "files", + "trust_server_cert": True, + "ignore_externals": True, + "non_interactive": True, + "quiet": True, + "check_returncode": True, + } + + native = SvnSync( + url=svn_remote_repo.as_uri(), path=tmp_path / "native", options=options + ) + native.obtain() + run = mocker.spy(native.cmd, "run") + result = native.update_repo() + assert result.ok, result.errors + update = next(call for call in run.call_args_list if call.args[0][0] == "update") + assert update.kwargs == { + "username": "reader", + "password": "secret", + "trust_server_cert": True, + "non_interactive": True, + "check_returncode": True, + } + assert "--ignore-externals" in update.args[0] + assert "--accept" in update.args[0] + assert "postpone" in update.args[0] diff --git a/tests/sync/test_preservation_crash.py b/tests/sync/test_preservation_crash.py new file mode 100644 index 000000000..d8afef317 --- /dev/null +++ b/tests/sync/test_preservation_crash.py @@ -0,0 +1,310 @@ +"""Outer acceptance: kill real owners and recover their retained native state.""" + +from __future__ import annotations + +import json +import os +import pathlib +import shutil +import signal +import socket +import subprocess +import sys +import typing as t + +import pytest + +from libvcs._internal.preservation import inventory +from libvcs.sync.base import SyncPolicy, SyncTarget +from libvcs.sync.git import GitSync +from libvcs.sync.hg import HgSync +from libvcs.sync.svn import SvnSync + +pytestmark = [ + pytest.mark.slow, # Process death, descendant reaping, and native recovery. + pytest.mark.skipif( + sys.platform != "linux", reason="requires Linux child subreaper" + ), +] + +Sync = GitSync | HgSync | SvnSync + +_LIBRARY = r""" +import json, os, socket, sys +from libvcs._internal.preservation import RecoveryStore +from libvcs.sync.base import SyncPolicy, SyncTarget +from libvcs.sync.git import GitSync +from libvcs.sync.hg import HgSync +from libvcs.sync.svn import SvnSync +backend, url, path, revision, phase, endpoint = sys.argv[1:] +native_phase = RecoveryStore.phase + +def publish(self, token, record, value): + native_phase(self, token, record, value) + if value == phase: + with socket.socket(socket.AF_UNIX) as connection: + connection.connect(endpoint) + event = {'pid': os.getpid(), 'phase': value} + connection.sendall(json.dumps(event).encode()) + connection.recv(1) + raise AssertionError('barrier released without process death') + +RecoveryStore.phase = publish +repo = {'git': GitSync, 'hg': HgSync, 'svn': SvnSync}[backend](url=url, path=path) +selector = {'rev' if backend == 'svn' else 'commit': revision} +result = repo.update_repo( + target=SyncTarget(**selector), policy=SyncPolicy(dirty='preserve') +) +raise AssertionError(repr(result)) +""" + +_SUPERVISOR = r""" +import ctypes, json, os, signal, subprocess, sys +# Adopt only this isolated supervisor's descendants, never pytest's children. +assert ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) == 0 +child = subprocess.Popen( + [sys.executable, '-c', sys.argv[1], *sys.argv[2:]], start_new_session=True +) +try: + event = json.load(sys.stdin) +finally: + expected = {child.pid} + if 'native' in event: + expected.update((event['native'], event['pid'])) + previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGCHLD}) + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + reaped = {} + while expected - reaped.keys(): + try: + while True: + pid, status = os.waitpid(-1, os.WNOHANG) + if pid == 0: + break + reaped[pid] = status + except ChildProcessError: + pass + if expected - reaped.keys(): + signal.sigwait({signal.SIGCHLD}) + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + code = os.waitstatus_to_exitcode(reaped[child.pid]) + print( + json.dumps( + {'owner': child.pid, 'code': code, 'reaped': list(reaped.items())} + ), + flush=True, + ) +""" + +_HOOK = r""" +import json, os, pathlib, socket +with socket.socket(socket.AF_UNIX) as connection: + connection.connect(ENDPOINT) + connection.sendall(json.dumps({ + 'pid': os.getpid(), 'native': os.getppid(), + 'advanced': pathlib.Path('advance.txt').read_text(), + }).encode()) + connection.recv(1) +raise AssertionError('hook released without process death') +""" + + +class Checkout(t.NamedTuple): + """Native source and the exact pre-capture and requested positions.""" + + repo: Sync + remote: pathlib.Path + base: str + target: str + status: str + + +def _backend(repo: Sync) -> str: + return {GitSync: "git", HgSync: "hg", SvnSync: "svn"}[type(repo)] + + +def _status(repo: Sync) -> str: + arguments = { + "git": ["status", "--porcelain=v1"], + "hg": ["status", "--copies"], + "svn": ["status"], + } + return repo.cmd.run(arguments[_backend(repo)], check_returncode=True) + + +@pytest.fixture +def checkout(request: pytest.FixtureRequest, tmp_path: pathlib.Path) -> Checkout: + """Reuse native fixture configuration while keeping the remote disposable.""" + backend = request.param + template = request.getfixturevalue(f"{backend}_repo") + remote_template = pathlib.Path(template.url.removeprefix("file://")) + remote = tmp_path / "remote" + shutil.copytree(remote_template, remote) + repo = type(template)(url=remote.as_uri(), path=tmp_path / "source") + repo.obtain() + for name in ("local.txt", "advance.txt"): + (repo.path / name).write_text("base\n") + repo.cmd.run(["add", "local.txt", "advance.txt"], check_returncode=True) + repo.cmd.run(["commit", "-m", "base"], check_returncode=True) + if backend == "svn": + repo.cmd.run(["update"], check_returncode=True) + base = repo.get_position().revision + (repo.path / "advance.txt").write_text("target\n") + if backend == "git": + repo.cmd.run(["add", "advance.txt"], check_returncode=True) + repo.cmd.run(["commit", "-m", "advance"], check_returncode=True) + if backend == "svn": + repo.cmd.run(["update"], check_returncode=True) + target = repo.get_position().revision + rollback = { + "git": ["checkout", "--detach", base], + "hg": ["update", "-r", base], + "svn": ["update", "-r", base], + } + repo.cmd.run(rollback[backend], check_returncode=True) + (repo.path / "local.txt").write_text("staged\n") + if backend == "git": + repo.cmd.run(["add", "local.txt"], check_returncode=True) + (repo.path / "local.txt").write_text("working\n") + (repo.path / "unknown.txt").write_bytes(b"unknown\x00bytes\n") + return Checkout(repo, remote, base, target, _status(repo)) + + +def _kill_at_barrier( + checkout: Checkout, phase: str, tmp_path: pathlib.Path +) -> tuple[dict[str, t.Any], dict[str, t.Any]]: + repo = checkout.repo + endpoint = str(tmp_path / "event.sock") + if phase == "native": + hook = repo.path / ".git" / "hooks" / "post-checkout" + hook.write_text(f"#!{sys.executable}\nENDPOINT = {endpoint!r}\n" + _HOOK) + hook.chmod(0o700) + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(endpoint) + listener.listen(1) + listener.settimeout(10) + with subprocess.Popen( + [ + sys.executable, + "-c", + _SUPERVISOR, + _LIBRARY, + _backend(repo), + repo.url, + str(repo.path), + checkout.target, + phase, + endpoint, + ], + env={ + **os.environ, + "PYTHONPATH": str(pathlib.Path(__file__).resolve().parents[2] / "src"), + }, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) as supervisor: + try: + connection, _ = listener.accept() + with connection: + connection.settimeout(10) + event = json.loads(connection.recv(4096)) + # The blocked process must still exist when its event arrives. + os.kill(event["pid"], 0) + if phase == "native": + assert event["advanced"] == "target\n" + assert repo.get_position().revision == checkout.target + native = pathlib.Path(f"/proc/{event['native']}/comm") + assert native.read_text().strip() == "git" + stdout, stderr = supervisor.communicate( + json.dumps(event), timeout=10 + ) + except TimeoutError: + stdout, stderr = supervisor.communicate("kill", timeout=10) + pytest.fail(f"barrier not reached: {stdout} {stderr}") + finally: + if supervisor.poll() is None: + supervisor.communicate("kill", timeout=10) + assert supervisor.returncode == 0, stderr + death = json.loads(stdout) + assert death["code"] == -signal.SIGKILL + if phase == "native": + reaped = dict(death["reaped"]) + assert os.WIFSIGNALED(reaped[event["native"]]) + assert os.WTERMSIG(reaped[event["native"]]) == signal.SIGKILL + assert event["pid"] in reaped + else: + assert death["owner"] == event["pid"] + return event, death + + +@pytest.mark.parametrize("checkout", ["git", "hg", "svn"], indirect=True) +@pytest.mark.parametrize("phase", ["sealed", "updating", "inspecting"]) +def test_process_death_after_published_capture( + checkout: Checkout, phase: str, tmp_path: pathlib.Path +) -> None: + """Killed owners leave discoverable uncertainty and repeatable offline recovery.""" + _kill_at_barrier(checkout, phase, tmp_path) + _assert_recovery(checkout, phase, tmp_path, advanced=phase == "inspecting") + + +@pytest.mark.parametrize("checkout", ["git"], indirect=True) +def test_process_death_during_native_checkout( + checkout: Checkout, tmp_path: pathlib.Path +) -> None: + """Kill Git itself after checkout progress while its post-checkout hook blocks.""" + _kill_at_barrier(checkout, "native", tmp_path) + _assert_recovery(checkout, "updating", tmp_path, advanced=True) + + +def _assert_recovery( + checkout: Checkout, phase: str, tmp_path: pathlib.Path, *, advanced: bool = False +) -> None: + repo = checkout.repo + source_before = inventory(repo.path) + retained = repo.list_recoveries() + assert len(retained) == 1 + interrupted = retained[0] + assert not interrupted.ok + assert interrupted.update_state == "unknown" + assert interrupted.preservation_state == "unknown" + assert [error.step for error in interrupted.errors] == ["interrupted"] + token = interrupted.recovery + assert token is not None + record_path = pathlib.Path(token.location) / "operation.json" + record_bytes = record_path.read_bytes() + record = json.loads(record_bytes) + assert record["phase"] == phase + assert repo.get_position().revision == ( + checkout.target if advanced else checkout.base + ) + assert inventory(repo.path) == source_before + result = repo.update_repo( + target=SyncTarget( + **{"rev" if _backend(repo) == "svn" else "commit": checkout.target} + ), + policy=SyncPolicy(dirty="preserve"), + ) + assert not result.ok + assert result.recovery == token + assert inventory(repo.path) == source_before + assert record_path.read_bytes() == record_bytes + shutil.rmtree(checkout.remote) + for attempt in range(2): + destination = tmp_path / f"recovered-{attempt}" + recovered = repo.recover_changes(token, destination=destination) + assert recovered.ok, recovered.errors + copy = type(repo)(url=repo.url, path=destination) + assert copy.get_position().revision == checkout.base + assert (destination / "local.txt").read_text() == "working\n" + assert (destination / "unknown.txt").read_bytes() == b"unknown\x00bytes\n" + assert (destination / "advance.txt").read_text() == "base\n" + assert _status(copy) == checkout.status + if _backend(repo) == "git": + assert copy.cmd.run(["show", ":local.txt"]) == "staged\n" + assert inventory(repo.path) == source_before + assert record_path.read_bytes() == record_bytes + assert repo.list_recoveries()[0].recovery == token diff --git a/tests/sync/test_svn.py b/tests/sync/test_svn.py index 3d0d888ed..5ee1a6a48 100644 --- a/tests/sync/test_svn.py +++ b/tests/sync/test_svn.py @@ -4,12 +4,13 @@ import shutil import typing as t +import xml.etree.ElementTree as et import pytest from libvcs import exc -from libvcs.sync.base import SyncResult -from libvcs.sync.svn import SvnSync +from libvcs.sync.base import SyncPolicy, SyncResult, SyncTarget +from libvcs.sync.svn import SvnOptions, SvnSync if t.TYPE_CHECKING: import pathlib @@ -20,6 +21,61 @@ pytestmark = pytest.mark.skip(reason="svn is not available") +def test_svn_position_reports_local_url_and_revision( + tmp_path: pathlib.Path, + svn_remote_repo_with_files: pathlib.Path, +) -> None: + """Position reads the working copy after its remote becomes unavailable.""" + remote = tmp_path / "remote" + shutil.copytree(svn_remote_repo_with_files, remote) + repo = SvnSync(url=remote.as_uri(), path=tmp_path / "copy") + repo.obtain() + shutil.rmtree(remote) + + position = repo.get_position() + + assert (position.ref_kind, position.ref_name) == ("url", repo.url) + assert position.revision == "3" + assert position.follows + assert not position.mixed + assert not position.switched + + +def test_svn_position_reports_mixed_and_switched_subtrees( + tmp_path: pathlib.Path, + create_svn_remote_repo: CreateRepoFn, +) -> None: + """A root revision cannot stand in for mixed or switched child entries.""" + remote = create_svn_remote_repo() + repo = SvnSync(url=remote.as_uri(), path=tmp_path / "copy") + repo.obtain() + (repo.path / "trunk").mkdir() + (repo.path / "trunk" / "file.txt").write_text("first\n") + repo.cmd.run(["add", "trunk"]) + assert not repo.get_position().mixed + repo.cmd.run(["commit", "-m", "add trunk"]) + + position = repo.get_position() + assert position.mixed + assert position.revision == "0" + + repo.cmd.run(["update"]) + repo.cmd.run(["copy", "trunk", "branch"]) + repo.cmd.run(["commit", "-m", "copy branch"]) + repo.cmd.run(["update"]) + repo.cmd.run(["copy", "-r", "1", f"{repo.url}/trunk", "old-copy"]) + assert not repo.get_position().mixed + repo.cmd.run(["propset", "svn:externals", "--", "-r1 ^/trunk external-copy", "."]) + repo.cmd.run(["update"]) + assert not repo.get_position().mixed + repo.cmd.run(["switch", f"{repo.url}/branch", "trunk"]) + + position = repo.get_position() + assert not position.mixed + assert position.switched + assert position.ref_name == repo.url + + def test_svn_sync(tmp_path: pathlib.Path, svn_remote_repo: pathlib.Path) -> None: """Tests for SvnSync.""" repo_name = "my_svn_project" @@ -59,6 +115,23 @@ def test_svn_sync_with_files( assert (tmp_path / repo_name).exists() +def test_svn_options_establish_ambient_checkout_depth( + tmp_path: pathlib.Path, + svn_remote_repo_with_files: pathlib.Path, +) -> None: + """An empty-depth checkout creates only the working-copy root.""" + checkout = tmp_path / "empty-checkout" + repo = SvnSync( + url=svn_remote_repo_with_files.as_uri(), + path=checkout, + options=SvnOptions(depth="empty"), + ) + + repo.obtain() + + assert {entry.name for entry in checkout.iterdir()} == {".svn"} + + def test_repo_svn_remote_checkout( create_svn_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, @@ -78,7 +151,7 @@ def test_repo_svn_remote_checkout( assert svn_repo_checkout_dir.exists() -def test_update_repo_checkout_failure_returns_sync_result( +def test_update_repo_target_failure_returns_sync_result( create_svn_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, @@ -98,5 +171,785 @@ def test_update_repo_checkout_failure_returns_sync_result( assert isinstance(result, SyncResult) assert result.ok is False assert len(result.errors) > 0 - assert result.errors[0].step == "checkout" + assert result.errors[0].step == "target" assert isinstance(result.errors[0].exception, exc.CommandError) + + +def test_svn_dirty_abort_precedes_update( + tmp_path: pathlib.Path, svn_remote_repo_with_files: pathlib.Path +) -> None: + """Default dirty policy leaves the checkout and unknown files untouched.""" + repo = SvnSync(url=svn_remote_repo_with_files.as_uri(), path=tmp_path / "copy") + repo.obtain() + (repo.path / "unknown").write_bytes(b"local\0bytes") + result = repo.update_repo() + assert not result.ok + assert result.errors[0].step == "dirty" + assert (repo.path / "unknown").read_bytes() == b"local\0bytes" + + +def test_svn_retained_copy_recovers_offline_twice( + tmp_path: pathlib.Path, svn_remote_repo_with_files: pathlib.Path +) -> None: + """Retained physical copies recover schedules and binary properties offline.""" + remote = tmp_path / "remote" + shutil.copytree(svn_remote_repo_with_files, remote) + repo = SvnSync(url=remote.as_uri(), path=tmp_path / "copy") + repo.obtain() + (repo.path / "added").write_bytes(b"added\0bytes") + repo.cmd.run(["add", "added"]) + blob = tmp_path / "blob" + blob.write_bytes(b"property\0\xff") + repo.cmd.run(["propset", "test:binary", "--file", str(blob), "added"]) + (repo.path / "unknown").write_bytes(b"unknown\0bytes") + result = repo.update_repo( + target=SyncTarget(rev="2"), policy=SyncPolicy(dirty="preserve") + ) + assert result.ok, result.errors + assert result.recovery is not None + before = et.canonicalize(repo.cmd.run(["status", "--xml", "--no-ignore"])) + shutil.rmtree(remote) + for index in range(2): + destination = tmp_path / f"recovered-{index}" + recovered = repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + clone = SvnSync(url=repo.url, path=destination) + assert ( + et.canonicalize(clone.cmd.run(["status", "--xml", "--no-ignore"])) == before + ) + assert et.canonicalize( + clone.cmd.run(["proplist", "--xml", "--verbose", "added"]) + ) == et.canonicalize(repo.cmd.run(["proplist", "--xml", "--verbose", "added"])) + assert (destination / "added").read_bytes() == b"added\0bytes" + assert (destination / "unknown").read_bytes() == b"unknown\0bytes" + repo.release_changes(result.recovery) + assert repo.list_recoveries() == () + + +@pytest.fixture +def svn_pair( + tmp_path: pathlib.Path, create_svn_remote_repo: CreateRepoFn +) -> tuple[SvnSync, SvnSync, pathlib.Path]: + """Create related native working copies with file and directory history.""" + remote = create_svn_remote_repo() + source = SvnSync(url=remote.as_uri(), path=tmp_path / "source") + source.obtain() + (source.path / "trunk").mkdir() + source.cmd.run(["add", "trunk"]) + source.cmd.run(["commit", "-m", "layout"]) + source = SvnSync(url=remote.as_uri() + "/trunk", path=tmp_path / "upstream") + source.obtain() + (source.path / "file").write_text("first\n" + "middle\n" * 12 + "last\n") + (source.path / "missing").write_text("missing base\n") + (source.path / "folder").mkdir() + (source.path / "folder" / "child").write_text("child base\n") + source.cmd.run(["add", "file", "missing", "folder"]) + source.cmd.run(["propset", "test:property", "base", "file"]) + source.cmd.run(["commit", "-m", "base"]) + repo = SvnSync(url=source.url, path=tmp_path / "copy") + repo.obtain() + return source, repo, remote + + +@pytest.mark.parametrize( + "kind", ["disjoint", "switch", "text", "property", "tree", "unknown", "binary"] +) +def test_svn_native_merge_and_conflicts( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], tmp_path: pathlib.Path, kind: str +) -> None: + """Native update and switch preserve dirt or expose conflicts with recovery.""" + from libvcs._internal import svn_preservation + + source, repo, _ = svn_pair + if kind == "switch": + source.cmd.run( + [ + "copy", + source.url, + source.url.removesuffix("/trunk") + "/branch", + "-m", + "branch", + ] + ) + source = SvnSync( + url=source.url.removesuffix("/trunk") + "/branch", path=tmp_path / "branch" + ) + source.obtain() + repo.url = source.url + (repo.path / "file.orig").write_text("caller backup\n") + if kind == "tree": + repo.cmd.run(["delete", "file"]) + elif kind == "property": + repo.cmd.run(["propset", "test:property", "local", "file"]) + elif kind == "unknown": + (repo.path / "incoming").write_text("local\n") + elif kind == "binary": + (repo.path / "file").write_bytes(b"local\0binary") + else: + (repo.path / "file").write_text("local\n" + "middle\n" * 12 + "last\n") + original = svn_preservation.WorkingCopy(repo.path).native() + if kind == "property": + source.cmd.run(["propset", "test:property", "upstream", "file"]) + elif kind == "unknown": + (source.path / "incoming").write_text("upstream\n") + source.cmd.run(["add", "incoming"]) + elif kind == "binary": + (source.path / "file").write_bytes(b"upstream\0binary") + else: + (source.path / "file").write_text( + ("upstream\n" if kind == "text" else "first\n") + + "middle\n" * 12 + + "upstream last\n" + ) + source.cmd.run(["commit", "-m", "upstream"]) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.recovery is not None, result.errors + if kind in {"disjoint", "switch"}: + assert result.ok, result.errors + assert ( + repo.path / "file" + ).read_text() == "local\n" + "middle\n" * 12 + "upstream last\n" + else: + assert not result.ok + assert result.conflicts + assert result.preservation_state == "conflicted" + assert (repo.path / "file.orig").read_text() == "caller backup\n" + destination = tmp_path / "recovered" + recovered = repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + assert svn_preservation.WorkingCopy(destination).native() == original + + +@pytest.mark.parametrize( + "kind,changed", + [("missing", False), ("missing", True), ("folder", False), ("folder", True)], +) +def test_svn_missing_intent_guards_upstream( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], kind: str, changed: bool +) -> None: + """Unchanged missing scopes stay absent; changed descendants stay present.""" + source, repo, _ = svn_pair + path = repo.path / kind + if kind == "folder": + shutil.rmtree(path) + else: + path.unlink() + affected = source.path / kind / "child" if kind == "folder" else source.path / kind + if changed: + affected.write_text("upstream change\n") + else: + (source.path / "file").write_text("unrelated upstream\n") + source.cmd.run(["commit", "-m", "upstream"]) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert result.recovery is not None, result.errors + assert result.ok is not changed, result.errors + assert path.exists() is changed + if changed: + assert result.conflicts[0].reason == "missing-intent-upstream-changed" + + +@pytest.mark.parametrize("scope", ["ignored", "symlink", "descendant"]) +def test_svn_missing_guard_rejects_unowned_scope( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], scope: str +) -> None: + """Physical obstructions and descendant-only identity changes block deletion.""" + from libvcs._internal import svn_preservation + + _, repo, _ = svn_pair + wc = svn_preservation.WorkingCopy(repo.path) + if scope == "ignored": + repo.cmd.run(["propset", "svn:ignore", "cache", "folder"]) + repo.cmd.run(["commit", "-m", "ignore cache"]) + shutil.rmtree(repo.path / "folder") + original = wc.native() + repo.cmd.run(["update"]) + if scope == "ignored": + (repo.path / "folder" / "cache").write_text("ignored\n") + elif scope == "symlink": + shutil.rmtree(repo.path / "folder") + (repo.path / "folder").symlink_to(repo.path.parent) + else: + document = et.fromstring(original["info"]) + for entry in document.findall("entry"): + if entry.get("path") == "folder/child": + commit = entry.find("commit") + assert commit is not None + commit.set("revision", "0") + original["info"] = et.tostring(document, encoding="unicode") + result = svn_preservation.restore_missing(wc, original) + assert result + assert (repo.path / "folder").exists() + + +@pytest.mark.parametrize( + "obstruction", ["database", "schema", "external", "nested", "subdirectory"] +) +def test_svn_preconditions_leave_source_unchanged( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + obstruction: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unsupported scope and native activity fail before capture or update.""" + import sqlite3 + + _, repo, _ = svn_pair + database = sqlite3.connect(repo.path / ".svn" / "wc.db", timeout=0) + try: + if obstruction == "database": + database.execute("BEGIN IMMEDIATE") + elif obstruction == "schema": + database.execute("PRAGMA user_version=999") + elif obstruction == "external": + repo.cmd.run(["propset", "svn:externals", "^/trunk external", "."]) + elif obstruction == "nested": + (repo.path / "nested" / ".svn").mkdir(parents=True) + elif obstruction == "subdirectory": + repo = SvnSync(url=repo.url + "/folder", path=repo.path / "folder") + + def reject_obtain() -> None: + message = "subdirectory must not run checkout" + raise AssertionError(message) + + monkeypatch.setattr(repo, "obtain", reject_obtain) + (repo.path / "unknown").write_text("local\n") + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is None + assert (repo.path / "unknown").read_text() == "local\n" + finally: + database.rollback() + database.close() + + +def test_svn_offline_copy_history_and_physical_state( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], tmp_path: pathlib.Path +) -> None: + """Recovery needs no remote history even for copies from outside the checkout.""" + from libvcs._internal import svn_preservation + + source, repo, remote = svn_pair + source.cmd.run( + ["copy", source.url + "/file", remote.as_uri() + "/outside", "-m", "outside"] + ) + repo.cmd.run(["copy", remote.as_uri() + "/outside", "copied"]) + repo.cmd.run(["move", "missing", "moved"]) + repo.cmd.run(["delete", "file"]) + (repo.path / "file").write_text("replacement\n") + repo.cmd.run(["add", "file"]) + repo.cmd.run(["changelist", "local", "copied"]) + repo.cmd.run(["propset", "svn:ignore", "cache", "."]) + (repo.path / "cache").write_bytes(b"ignored\0bytes") + (repo.path / "unknown-dir").mkdir() + (repo.path / "unknown-dir" / "file").write_bytes(b"unknown\0bytes") + (repo.path / "link").symlink_to("copied") + (repo.path / "copied").chmod(0o751) + original = svn_preservation.WorkingCopy(repo.path).native() + result = repo.update_repo( + target=SyncTarget(rev="2"), policy=SyncPolicy(dirty="preserve") + ) + assert result.recovery is not None, result.errors + shutil.rmtree(remote) + for index in range(2): + destination = tmp_path / f"offline-{index}" + recovered = repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + assert svn_preservation.WorkingCopy(destination).native() == original + assert (destination / "cache").read_bytes() == b"ignored\0bytes" + assert (destination / "unknown-dir" / "file").read_bytes() == b"unknown\0bytes" + assert (destination / "link").readlink().as_posix() == "copied" + assert (destination / "copied").stat().st_mode & 0o777 == 0o751 + + +@pytest.mark.parametrize( + "fault", + [ + "capture", + "sealed", + "updating", + "update", + "inspection", + "inspection-schema", + "absence", + "publication", + ], +) +def test_svn_failure_paths_retain_tokens( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + fault: str, +) -> None: + """Every post-intent failure retains its token and prevents unsafe continuation.""" + from libvcs._internal import preservation, svn_preservation + + source, repo, _ = svn_pair + (source.path / "file").write_text("upstream\n") + source.cmd.run(["commit", "-m", "upstream"]) + (repo.path / "unknown").write_text("local\n") + before = svn_preservation.WorkingCopy(repo.path).native() + real_run, real_phase = repo.cmd.run, preservation.RecoveryStore.phase + real_read = svn_preservation.WorkingCopy.read + updated = False + + def run(args: t.Any, **kwargs: t.Any) -> str: + nonlocal updated + if "update" in args: + updated = True + if fault == "update": + raise exc.CommandError( + cmd=args, returncode=1, output="original update error" + ) + return real_run(args, **kwargs) + + def phase(store: t.Any, token: t.Any, record: t.Any, value: str) -> None: + if value == fault: + raise OSError(fault) + real_phase(store, token, record, value) + + def fail(*args: t.Any, **kwargs: t.Any) -> t.Any: + raise OSError(fault) + + def read(wc: t.Any, args: list[str]) -> bytes: + if updated and args[0] == "status": + return b"" + return real_read(wc, args) + + with monkeypatch.context() as patch: + patch.setattr(repo.cmd, "run", run) + patch.setattr(preservation.RecoveryStore, "phase", phase) + if fault == "capture": + patch.setattr(shutil, "copytree", fail) + if fault == "inspection": + patch.setattr( + svn_preservation, + "conflicts", + lambda native: () if not updated else fail(), + ) + if fault == "inspection-schema": + patch.setattr(svn_preservation.WorkingCopy, "read", read) + if fault == "absence": + patch.setattr(svn_preservation, "restore_missing", fail) + if fault == "publication": + patch.setattr(preservation.RecoveryStore, "finish", fail) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is not None + expected_step = ( + "capture" + if fault in {"capture", "sealed"} + else "update" + if fault in {"updating", "update"} + else "inspection" + if fault in {"inspection", "inspection-schema", "absence"} + else "publication" + ) + assert result.errors[0].step == expected_step + found = repo.list_recoveries()[0] + assert found.recovery == result.recovery + if fault in {"capture", "sealed", "updating"}: + assert not updated + assert svn_preservation.WorkingCopy(repo.path).native() == before + if fault != "capture": + recovered = repo.recover_changes( + result.recovery, destination=tmp_path / "recovered" + ) + assert recovered.ok, recovered.errors + else: + assert not found.ok + + +@pytest.mark.parametrize("fault", ["copy", "verify", "publish"]) +def test_svn_recovery_failure_keeps_owned_staging( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + fault: str, +) -> None: + """Failed recovery retains staging, source, sealed data, and the same token.""" + import pathlib + + _, repo, _ = svn_pair + (repo.path / "unknown").write_text("local\n") + saved = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert saved.ok, saved.errors + assert saved.recovery is not None + real_copy = shutil.copytree + real_rename = pathlib.Path.rename + + def copy(source: t.Any, destination: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: + output = real_copy(source, destination, *args, **kwargs) + if pathlib.Path(destination).name != "wc": + return output + if fault == "copy": + message = "recovery copy failed" + raise OSError(message) + if fault == "verify": + (pathlib.Path(destination) / "unknown").write_text("wrong\n") + return output + + def rename(source: pathlib.Path, target: t.Any) -> pathlib.Path: + if fault == "publish": + message = "recovery publication failed" + raise OSError(message) + return real_rename(source, target) + + with monkeypatch.context() as patch: + patch.setattr(shutil, "copytree", copy) + patch.setattr(pathlib.Path, "rename", rename) + result = repo.recover_changes( + saved.recovery, destination=tmp_path / "recovered" + ) + assert not result.ok + assert result.recovery == saved.recovery + assert "staging retained at" in result.errors[0].message + expected = { + "copy": "recovery copy failed", + "verify": "does not match sealed state", + "publish": "recovery publication failed", + } + assert expected[fault] in result.errors[0].message + assert list(tmp_path.glob(".libvcs-recover-*")) + assert (repo.path / "unknown").read_text() == "local\n" + assert repo.recover_changes(saved.recovery, destination=tmp_path / "second").ok + + +def test_svn_ignored_collision_keeps_local_bytes( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], +) -> None: + """An ignored-only obstruction remains intact when upstream tracks that path.""" + source, repo, _ = svn_pair + repo.cmd.run(["propset", "svn:ignore", "cache", "."]) + repo.cmd.run(["commit", "-m", "ignore"]) + (repo.path / "cache").write_bytes(b"local ignored\0bytes") + assert not repo.is_dirty() + (source.path / "cache").write_bytes(b"upstream tracked\0bytes") + source.cmd.run(["add", "cache"]) + source.cmd.run(["commit", "-m", "track cache"]) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.conflicts or result.errors + assert (repo.path / "cache").read_bytes() == b"local ignored\0bytes" + + +def test_svn_explicit_discard_retains_ignored_files( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], +) -> None: + """Discard reverts schedules and ordinary dirt without deleting ignored files.""" + source, repo, _ = svn_pair + repo.cmd.run(["propset", "svn:ignore", "cache", "."]) + repo.cmd.run(["commit", "-m", "ignore"]) + source.cmd.run(["update"]) + (source.path / "file").write_text("upstream\n") + source.cmd.run(["commit", "-m", "advance"]) + (repo.path / "file").write_text("local\n") + repo.cmd.run(["delete", "missing"]) + (repo.path / "added").write_text("added\n") + repo.cmd.run(["add", "added"]) + (repo.path / "unknown").write_text("unknown\n") + (repo.path / "cache").write_text("ignored\n") + result = repo.update_repo(policy=SyncPolicy(dirty="discard")) + assert result.ok, result.errors + assert result.recovery is None + assert not repo.is_dirty() + assert (repo.path / "file").read_text() == "upstream\n" + assert (repo.path / "missing").exists() + assert not (repo.path / "added").exists() + assert not (repo.path / "unknown").exists() + assert (repo.path / "cache").read_text() == "ignored\n" + + +@pytest.mark.parametrize("drift", ["keep", "warn"]) +def test_svn_drift_uses_local_revision_and_url( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + caplog: pytest.LogCaptureFixture, + drift: t.Literal["keep", "warn"], +) -> None: + """Numeric target comparison stays offline and emits structured drift records.""" + _, repo, remote = svn_pair + position = repo.get_position() + (repo.path / "unknown").write_text("local\n") + shutil.rmtree(remote) + result = repo.update_repo(target=SyncTarget(rev=1), policy=SyncPolicy(drift=drift)) + assert result.ok, result.errors + assert repo.get_position() == position + assert (repo.path / "unknown").read_text() == "local\n" + warnings = [record for record in caplog.records if record.name == "libvcs.sync.svn"] + assert bool(warnings) is (drift == "warn") + if warnings: + assert warnings[0].__dict__["vcs_event"] == "target_drift" + caplog.clear() + result = repo.update_repo( + target=SyncTarget(rev=position.revision), policy=SyncPolicy(drift="warn") + ) + assert result.ok + assert not [record for record in caplog.records if record.name == "libvcs.sync.svn"] + with pytest.raises(ValueError, match="HEAD"): + repo.resolve_target(SyncTarget(rev="HEAD")) + + +@pytest.mark.parametrize( + "target", + [ + SyncTarget(branch="main"), + SyncTarget(tag="v1"), + SyncTarget(commit="1"), + SyncTarget(rev="1", remote="origin"), + ], +) +def test_svn_rejects_nonrevision_targets_before_checkout( + tmp_path: pathlib.Path, target: SyncTarget +) -> None: + """SVN never guesses branch layouts or starts a checkout for invalid selectors.""" + repo = SvnSync(url="file:///unavailable", path=tmp_path / "copy") + result = repo.update_repo(target=target) + assert not result.ok + assert result.errors[0].step == "target" + assert not repo.path.exists() + + +@pytest.mark.parametrize("damage", ["phase", "native", "material"]) +def test_svn_damaged_record_exposes_token( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], tmp_path: pathlib.Path, damage: str +) -> None: + """Malformed records and damaged copies remain visible, unreleased errors.""" + import json + import pathlib + + _, repo, _ = svn_pair + (repo.path / "unknown").write_text("local\n") + saved = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert saved.ok, saved.errors + assert saved.recovery is not None + operation = pathlib.Path(saved.recovery.location) / "operation.json" + if damage == "material": + (operation.parent / "material" / "wc" / "unknown").write_text("changed\n") + else: + record = json.loads(operation.read_text()) + if damage == "phase": + record["phase"] = [] + else: + record["native"]["metadata"] = [] + operation.write_text(json.dumps(record)) + result = repo.list_recoveries()[0] + assert not result.ok + assert result.recovery == saved.recovery + recovered = repo.recover_changes(saved.recovery, destination=tmp_path / "recovered") + assert not recovered.ok + assert recovered.recovery == saved.recovery + with pytest.raises(ValueError): + repo.release_changes(saved.recovery) + if damage != "material": + result = repo.update_repo() + assert not result.ok + assert result.recovery == saved.recovery + + +def test_svn_interrupted_publication_is_discoverable( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A sealed copy survives interruption before its phase record is published.""" + from libvcs._internal import preservation + + _, repo, remote = svn_pair + (repo.path / "unknown").write_text("local\n") + real_phase = preservation.RecoveryStore.phase + + def interrupt(store: t.Any, token: t.Any, record: t.Any, phase: str) -> None: + if phase == "sealed": + raise KeyboardInterrupt + real_phase(store, token, record, phase) + + with monkeypatch.context() as patch: + patch.setattr(preservation.RecoveryStore, "phase", interrupt) + with pytest.raises(KeyboardInterrupt): + repo.update_repo(policy=SyncPolicy(dirty="preserve")) + saved = repo.list_recoveries()[0] + assert not saved.ok + assert saved.recovery is not None + assert repo.update_repo().recovery == saved.recovery + shutil.rmtree(remote) + result = repo.recover_changes(saved.recovery, destination=tmp_path / "recovered") + assert result.ok, result.errors + + +@pytest.mark.parametrize("replacement", [False, True]) +def test_svn_recovery_survives_source_loss( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + tmp_path: pathlib.Path, + replacement: bool, +) -> None: + """Sealed copies recover offline without reading or mutating a replacement WC.""" + from libvcs._internal import svn_preservation + + _, repo, remote = svn_pair + (repo.path / "unknown").write_bytes(b"original\0bytes") + saved = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert saved.ok, saved.errors + assert saved.recovery is not None + shutil.rmtree(remote) + shutil.rmtree(repo.path) + if replacement: + (repo.path / ".svn").mkdir(parents=True) + (repo.path / "replacement").write_bytes(b"untouched\0bytes") + before = svn_preservation.tree(repo.path) + for index in range(2): + destination = tmp_path / f"independent-{index}" + result = repo.recover_changes(saved.recovery, destination=destination) + assert result.ok, result.errors + assert (destination / "unknown").read_bytes() == b"original\0bytes" + assert repo.list_recoveries()[0].recovery == saved.recovery + repo.release_changes(saved.recovery) + assert repo.list_recoveries() == () + if replacement: + assert svn_preservation.tree(repo.path) == before + else: + assert not repo.path.exists() + + +def test_svn_native_partial_failure_keeps_first_error( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A native multi-target failure after progress retains original recovery state.""" + from libvcs._internal import svn_preservation + + source, repo, remote = svn_pair + other = remote.as_uri() + "/other" + source.cmd.run(["copy", source.url + "/folder", other, "-m", "other"]) + repo.cmd.run(["switch", other, "folder"]) + (repo.path / "unknown").write_text("local\n") + original = svn_preservation.WorkingCopy(repo.path).native() + (source.path / "file").write_text("upstream\n") + source.cmd.run(["commit", "-m", "advance"]) + source.cmd.run(["delete", other, "-m", "remove switched target"]) + real_run = repo.cmd.run + + def partial(args: list[str], **kwargs: t.Any) -> str: + if args[0] == "update": + args = [*args[:-1], "file", "folder"] + return real_run(args, **kwargs) + + def reject_absence(*args: t.Any) -> t.Any: + message = "failed update must not delete missing scopes" + raise AssertionError(message) + + with monkeypatch.context() as patch: + patch.setattr(repo.cmd, "run", partial) + patch.setattr(svn_preservation, "restore_missing", reject_absence) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is not None + assert result.update_state == "failed" + assert result.errors[0].step == "update" + assert isinstance(result.errors[0].exception, exc.CommandError) + assert (repo.path / "file").read_text() == "upstream\n" + destination = tmp_path / "recovered" + recovered = repo.recover_changes(result.recovery, destination=destination) + assert recovered.ok, recovered.errors + assert svn_preservation.WorkingCopy(destination).native() == original + + +def test_svn_capture_detects_persistent_editor_change( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Capture failure preserves an observed external edit and starts no update.""" + import pathlib + + _, repo, _ = svn_pair + (repo.path / "unknown").write_text("local\n") + position = repo.get_position() + real_copy = shutil.copytree + + def edit(source: t.Any, destination: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: + output = real_copy(source, destination, *args, **kwargs) + if pathlib.Path(destination).name == "wc": + (repo.path / "unknown").write_text("editor changed\n") + return output + + with monkeypatch.context() as patch: + patch.setattr(shutil, "copytree", edit) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is not None + assert result.errors[0].step == "capture" + assert result.update_state == "not-started" + assert repo.get_position() == position + assert (repo.path / "unknown").read_text() == "editor changed\n" + + +def test_svn_rejects_linked_administrative_storage( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + tmp_path: pathlib.Path, +) -> None: + """A full-copy capture cannot depend on pristine objects outside its root.""" + from libvcs._internal import svn_preservation + + _, repo, _ = svn_pair + pristine = repo.path / ".svn" / "pristine" + external = tmp_path / "external-pristine" + pristine.rename(external) + pristine.symlink_to(external, target_is_directory=True) + (repo.path / "unknown").write_text("local\n") + before = svn_preservation.tree(repo.path) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.errors[0].step == "precondition" + assert result.recovery is None + assert svn_preservation.tree(repo.path) == before + + +def test_svn_initial_target_overrides_constructor_revision( + tmp_path: pathlib.Path, + svn_remote_repo_with_files: pathlib.Path, +) -> None: + """An explicit initial sync target replaces the constructor revision default.""" + repo = SvnSync( + url=svn_remote_repo_with_files.as_uri(), path=tmp_path / "copy", rev="999" + ) + result = repo.update_repo(target=SyncTarget(rev=2)) + assert result.ok, result.errors + assert repo.get_position().revision == "2" + + +def test_svn_missing_inspection_failure_remains_unknown( + svn_pair: tuple[SvnSync, SvnSync, pathlib.Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Invalid XML during absence inspection cannot be classified as a conflict.""" + from libvcs._internal import svn_preservation + + _, repo, _ = svn_pair + (repo.path / "missing").unlink() + real_run = repo.cmd.run + real_read = svn_preservation.WorkingCopy.read + inspections = 0 + updated = False + + def run(args: list[str], **kwargs: t.Any) -> str: + nonlocal updated + output = real_run(args, **kwargs) + if args[0] == "update": + updated = True + return output + + def read(wc: t.Any, args: list[str]) -> bytes: + nonlocal inspections + if updated and args[0] == "status": + inspections += 1 + if inspections == 2: + return b"" + return real_read(wc, args) + + with monkeypatch.context() as patch: + patch.setattr(repo.cmd, "run", run) + patch.setattr(svn_preservation.WorkingCopy, "read", read) + result = repo.update_repo(policy=SyncPolicy(dirty="preserve")) + assert not result.ok + assert result.recovery is not None + assert result.preservation_state == "unknown" + assert result.errors[0].step == "inspection" + assert (repo.path / "missing").exists()