From 432647c101e73844b6ede5235396c59a84b8504c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 15:31:35 -0500 Subject: [PATCH 1/9] docs(adr): Add ADR 0001 on subprocess output why: The legacy run() helper post-processes captured output (per-line strip, drops blank lines, no trailing newline), which corrupts whitespace-significant output -- captured diffs no longer apply, blob reads lose content, multi-line errors smash together. The decision and rejected alternatives need a durable record so the behavior isn't "simplified" back into the bug. what: - Record the decision: capture pristine, trim/decode at the edge, phased migration onto SubprocessCommand. - Capture the measured alternatives matrix and prior-art lessons (pip, uv, mise, mercurial, gitoxide, jujutsu, git) as evidence. - Start a docs/project/adr/ section with an index and toctree. --- ...0001-faithful-subprocess-output-capture.md | 165 ++++++++++++++++++ docs/project/adr/index.md | 11 ++ docs/project/index.md | 7 + 3 files changed, 183 insertions(+) create mode 100644 docs/project/adr/0001-faithful-subprocess-output-capture.md create mode 100644 docs/project/adr/index.md diff --git a/docs/project/adr/0001-faithful-subprocess-output-capture.md b/docs/project/adr/0001-faithful-subprocess-output-capture.md new file mode 100644 index 000000000..45d0662f8 --- /dev/null +++ b/docs/project/adr/0001-faithful-subprocess-output-capture.md @@ -0,0 +1,165 @@ +(adr-faithful-subprocess-output)= + +# ADR 0001: Faithful subprocess output capture + +## Status + +Proposed. 2026-06-20. + +## Context + +The legacy command runner `libvcs._internal.run.run` — used by every +`Git`, `Hg`, and `Svn` command class via `.run()` — does not return what +the underlying VCS actually printed. After the process exits it splits +captured stdout into lines, calls `bytes.strip()` on each line, drops any +line that is empty after stripping, and rejoins with `\n` and no trailing +newline. stderr is treated the same way and then rejoined with no +separator at all. + +That post-processing corrupts any output where whitespace is +significant: + +- Leading indentation is removed from every line. +- Blank lines (including a unified-diff blank context line, which is a + single space) are dropped. +- The trailing newline is removed. +- Multi-line error messages are concatenated word-against-word, because + stderr lines are rejoined with an empty separator. + +The user-visible consequence: a diff captured through `.run(["diff"])` +cannot be re-applied. `git apply` requires every patch line — including +the final one — to be newline-terminated, so a stripped diff is rejected +as `corrupt patch`. The same corruption silently damages `git show`, +`git cat-file blob` (file contents), `git format-patch`, and any +`--format` output that carries indentation or blank lines. Single-token +reads such as `rev-parse HEAD` are unaffected, which is why the defect +went unnoticed. + +This is not a regression. The line-cleanup dates to the project's +original progress-callback code, where it was meant to tidy +human-readable progress lines, not to capture structured output. The +runner's own module docstring already states that it "will be deprecated +by `libvcs._internal.subprocess`". + +`libvcs._internal.subprocess.SubprocessCommand` already exists as a thin, +typed wrapper that returns a real `subprocess.CompletedProcess` with +separate, untouched `stdout` and `stderr`. It is bytes-first with opt-in +text decoding, and it is currently wired into nothing. + +## Decision + +Subprocess output is captured pristine. Any trimming or decoding is a +deliberate transformation applied at the call site, never inside the +capture path. This is implemented in two phases so the stable +`.run() -> str` contract is never broken. + +### Phase 1 — stop corrupting in the capture path + +- Capture stdout and stderr as raw bytes and decode once, without + per-line stripping or blank-line dropping. Interior whitespace, blank + lines, and stream structure are preserved exactly. +- `.run()` keeps returning a convenience string. By default it applies a + single **whole-output** `rstrip()` (trailing whitespace only), + matching the established "bare value" behavior callers already rely on + for reads like `rev-parse HEAD`. +- Add an opt-in for verbatim output (e.g. `trim=False`) so callers that + need byte-accurate output — diffs destined for `git apply`, blob + contents — get exactly what the VCS produced, trailing newline + included. +- Decode stderr for error messages with `errors="backslashreplace"` and + preserve its line structure, so `libvcs.exc.CommandError.output` is + readable and never hides an undecodable byte. + +### Phase 2 — pristine structured backend + +- Route the `cmd/*` classes through + `libvcs._internal.subprocess.SubprocessCommand`, which returns a + `subprocess.CompletedProcess` (bytes-first, separate + stdout/stderr/returncode). +- Expose a structured accessor that returns the `CompletedProcess` for + callers that want streams, exit code, and exact bytes. Keep + `.run() -> str` (verbatim by default, `trim=True` opt-in) as the + convenience facade over it. +- Retire `libvcs._internal.run.console_to_str` in favor of subprocess's + own decoding with `errors="backslashreplace"`. +- Let the legacy `libvcs._internal.run.run` deprecate, as its docstring + already anticipates. + +## Alternatives considered + +Each candidate was measured against the test suite and a probe checking +whether a captured diff survives `git apply` and whether `cat-file blob` +is byte-identical. + +| Approach | diff applies | blob identical | `rev-parse` bare | errors intact | tests failing | +|----------|:------------:|:--------------:|:----------------:|:-------------:|:-------------:| +| Per-line `strip` + drop-blanks (current) | no | no | yes | no | baseline | +| Verbatim string everywhere | yes | yes | no (`+\n`) | yes | 77 | +| Whole-output `rstrip` | no | no | yes | yes | 1 | +| Per-call `trim` flag (chosen, Phase 1) | yes (opt-in) | yes (opt-in) | yes (default) | yes | 1 | +| Structured `CompletedProcess` (chosen, Phase 2) | yes | yes | edge decides | yes | 0 (via facade) | + +Two results are decisive. Returning fully verbatim output as the default +fixes fidelity but breaks 77 tests, because the project's own doctests +and downstream consumers expect `.run()` to return a value with no +trailing newline. A global trailing trim keeps that contract but cannot +produce an applyable patch, since the patch's required final newline is +exactly what gets trimmed. Only a per-call choice satisfies both, and a +structured result removes the choice from the runner entirely by handing +the caller pristine bytes plus separate streams. + +The single failing test under the chosen approaches is a `Svn.blame` +doctest whose expected value had encoded the bug (column-padding spaces +already stripped). It is corrected to expect the faithful output. + +## Consequences + +### Positive + +- Captured diffs and patches re-apply; blob reads are byte-identical; + error messages keep their line structure. +- The default `.run()` contract (no trailing newline) is preserved, so + existing callers and the downstream `vcspull`, which strips defensively + before comparing, are unaffected. +- Two latent defects are removed: stderr lines are no longer concatenated + without a separator, and routing through `subprocess.run` avoids the + pipe-buffer deadlock the legacy poll loop can hit when a child floods + stdout. + +### Tradeoffs + +- Callers that need pristine output must opt in (Phase 1) or use the + structured accessor (Phase 2); the convenience default still trims. +- Phase 2 introduces a second return shape (`CompletedProcess`) alongside + the string facade, and migrates the command classes onto a new backend. + +### Risks + +- Behavior drift between the `.run()` facade and the structured result. + Mitigation: the facade decodes the structured result's stdout (verbatim + by default; `trim=True` applies `rstrip`), not a separate code path. +- Encoding surprises on non-UTF-8 output. Mitigation: strict decode for + data with a documented `backslashreplace` fallback for diagnostics, + following the channel-split used by the tools surveyed below. + +## Prior art + +The decision follows the convergent practice of mature VCS and +subprocess-wrapping tools, none of which trim inside the capture path: + +- **pip** added a per-call mode (`stdout_only`) that returns VCS output + verbatim, and replaced its `console_to_str` helper with + `errors="backslashreplace"`. +- **uv** captures into a raw `Output { stdout, stderr }` and applies + `trim_end()` at each call site for scalar reads. +- **mise** relies on whole-output trailing trim for scalars, decodes + strictly for data and lossily for stderr. +- **Mercurial**, **gitoxide**, and **Jujutsu** are bytes-first and keep + output verbatim; gitoxide treats newline-stripping as a named, opt-in + view, and tracks the missing-final-newline case explicitly. +- **git** itself confirms the constraint: its patch parser requires each + line, including the last, to be newline-terminated. + +The lesson shared by all of them: capture pristine, keep streams +separate, and make trimming and decoding explicit edge transformations. +`SubprocessCommand` already embodies that shape inside libvcs. diff --git a/docs/project/adr/index.md b/docs/project/adr/index.md new file mode 100644 index 000000000..c763aa55a --- /dev/null +++ b/docs/project/adr/index.md @@ -0,0 +1,11 @@ +(adr)= + +# Architecture Decision Records + +Significant design decisions and their rationale. + +```{toctree} +:maxdepth: 1 + +0001-faithful-subprocess-output-capture +``` diff --git a/docs/project/index.md b/docs/project/index.md index 915e334f4..8000b8e7f 100644 --- a/docs/project/index.md +++ b/docs/project/index.md @@ -31,6 +31,12 @@ Ruff, mypy, NumPy docstrings, import conventions. Release checklist and version policy. ::: +:::{grid-item-card} Decision Records +:link: adr +:link-type: ref +Architecture decisions and their rationale. +::: + :::: ```{toctree} @@ -40,4 +46,5 @@ contributing workflow code-style releasing +adr/index ``` From 16a439ca999028ab03ccdf8ab479d4ecd687c660 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 17:27:16 -0500 Subject: [PATCH 2/9] run(fix[trim]): Capture output faithfully why: The runner trimmed each captured line and dropped blank lines, so whitespace-significant output was corrupted: captured diffs were rejected by `git apply`, `cat-file blob` lost indentation and blank lines, and multi-line stderr was concatenated into one run-on line. Single-token reads like `rev-parse HEAD` hid the defect. what: - Capture stdout/stderr verbatim; decode once; trim only the whole output (str.rstrip), preserving leading indentation, blank lines, and interior structure. - Add `trim` (default True). Pass `trim=False` for byte-faithful output -- diffs that feed `git apply`, exact blob contents. - Rejoin stderr without smashing lines together. - Correct the Svn.blame doctest, which had encoded the stripped output. - Cover trim=False fidelity, the default trim contract, and stderr line preservation with functional tests. --- src/libvcs/_internal/run.py | 46 +++++++++++++-------- src/libvcs/cmd/svn.py | 2 +- tests/cmd/test_git.py | 79 +++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/libvcs/_internal/run.py b/src/libvcs/_internal/run.py index e37e52733..d29df0e26 100644 --- a/src/libvcs/_internal/run.py +++ b/src/libvcs/_internal/run.py @@ -34,7 +34,7 @@ def console_to_str(s: bytes) -> str: try: return s.decode(console_encoding) except UnicodeDecodeError: - return s.decode("utf_8") + return s.decode("utf_8", errors="backslashreplace") except AttributeError: # for tests, #13 return str(s) @@ -149,6 +149,7 @@ def run( umask: int = -1, log_in_real_time: bool = False, check_returncode: bool = True, + trim: bool = True, callback: ProgressCallbackProtocol | None = None, timeout: float | None = None, ) -> str: @@ -157,6 +158,13 @@ def run( Run 'args' in a shell and return the combined contents of stdout and stderr (Blocking). Throws an exception if the command exits non-zero. + Output is captured verbatim. When ``trim`` is True (the default) + :meth:`str.rstrip` removes trailing whitespace from the whole output, + preserving leading indentation, blank lines, and interior structure. Pass + ``trim=False`` for byte-faithful output -- for example a ``git diff`` + destined for ``git apply``, which requires the trailing newline, or + ``git cat-file blob`` whose contents must round-trip exactly. + Keyword arguments are passthrough to :class:`subprocess.Popen`. Parameters @@ -181,6 +189,14 @@ def run( Indicate whether a ``libvcs.exc.CommandError`` should be raised if return code is different from 0. + trim : bool + When True (default), strip trailing whitespace from the whole output + for the convenient "bare value" reads callers expect (e.g. + ``rev-parse HEAD``). When False, return the output verbatim, including + any trailing newline, so whitespace-significant output (diffs, blob + contents) round-trips exactly. On a non-zero exit the captured stderr + used for the error output is trimmed the same way. + callback : ProgressCallbackProtocol callback to return output as a command executes, accepts a function signature of ``(output, timestamp)``. Example usage:: @@ -273,29 +289,25 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None: callback(output="\r", timestamp=datetime.datetime.now()) if proc.stdout is not None: - stdout_lines: list[bytes] = ( - timeout_stdout.split(b"\n") + raw_stdout: bytes = ( + timeout_stdout if timeout_stdout is not None - else proc.stdout.readlines() + else b"".join(proc.stdout.readlines()) ) - lines: t.Iterable[bytes] = filter( - None, - (line.strip() for line in stdout_lines), - ) - all_output = console_to_str(b"\n".join(lines)) + all_output = console_to_str(raw_stdout) + if trim: + all_output = all_output.rstrip() else: all_output = "" if code and proc.stderr is not None: - stderr_raw: list[bytes] = ( - timeout_stderr.split(b"\n") + raw_stderr: bytes = ( + timeout_stderr if timeout_stderr is not None - else proc.stderr.readlines() - ) - stderr_lines: t.Iterable[bytes] = filter( - None, - (line.strip() for line in stderr_raw), + else b"".join(proc.stderr.readlines()) ) - all_output = console_to_str(b"".join(stderr_lines)) + all_output = console_to_str(raw_stderr) + if trim: + all_output = all_output.rstrip() output = "".join(all_output) if code != 0 and check_returncode: raise exc.CommandError( diff --git a/src/libvcs/cmd/svn.py b/src/libvcs/cmd/svn.py index 3445c659c..fc1787df5 100644 --- a/src/libvcs/cmd/svn.py +++ b/src/libvcs/cmd/svn.py @@ -387,7 +387,7 @@ def blame( >>> svn.commit(path=new_file, message='My new commit') '...' >>> svn.blame('new.txt') - '4 ... example text' + ' 4 ... example text' """ local_flags: list[str] = [str(target)] diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 9a71886f3..07914c8d8 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -2704,3 +2704,82 @@ def test_rev_list_all_parameter(git_repo: GitSync) -> None: # _all=True should return strictly more commits (the other-branch commit) assert count_with_all > count_no_all + + +def test_run_trim_false_preserves_diff(git_repo: GitSync) -> None: + """run(trim=False) returns byte-faithful output a patch tool can apply. + + The default per-line trimming dropped leading indentation and blank + context lines and removed the trailing newline, so a captured + ``git diff`` was rejected by ``git apply`` as a corrupt patch. + """ + base = ( + "def greet(name):\n" + ' message = "hello, " + name\n' + "\n" + " print(message)\n" + " return message\n" + ) + target = git_repo.path / "greet.py" + target.write_text(base) + git_repo.cmd.run(["add", "greet.py"]) + git_repo.cmd.run(["commit", "-m", "Add greet.py"]) + target.write_text(base.replace("hello, ", "hi, ")) + + faithful = subprocess.run( + ["git", "diff", "--no-color", "--", "greet.py"], + cwd=git_repo.path, + capture_output=True, + text=True, + check=True, + ).stdout + captured = git_repo.cmd.run(["diff", "--no-color", "--", "greet.py"], trim=False) + assert captured == faithful + + # Restore the clean pre-image so the captured patch can be validated. + target.write_text(base) + check = subprocess.run( + ["git", "apply", "--check"], + cwd=git_repo.path, + input=captured, + capture_output=True, + text=True, + ) + assert check.returncode == 0, check.stderr + + +def test_run_trim_false_preserves_blob(git_repo: GitSync) -> None: + """run(trim=False) round-trips file contents byte-for-byte.""" + base = "a\n indented\n\nb\n" + target = git_repo.path / "blob.txt" + target.write_text(base) + git_repo.cmd.run(["add", "blob.txt"]) + git_repo.cmd.run(["commit", "-m", "Add blob.txt"]) + + blob = git_repo.cmd.run(["cat-file", "blob", "HEAD:blob.txt"], trim=False) + assert blob == base + + +def test_run_default_trims_trailing_newline(git_repo: GitSync) -> None: + """Default run() keeps the no-trailing-newline contract callers rely on.""" + sha = git_repo.cmd.run(["rev-parse", "HEAD"]) + + assert "\n" not in sha + assert sha == sha.strip() + + +def test_run_failure_preserves_stderr_lines(git_repo: GitSync) -> None: + """A failed command keeps stderr line structure in CommandError.output. + + Previously stderr lines were rejoined with no separator, smashing a + multi-line git error into a single run-on string. + """ + with pytest.raises(exc.CommandError) as excinfo: + git_repo.cmd.run(["push", "no-such-remote", "HEAD"]) + + output = excinfo.value.output + # Multi-line stderr keeps its line breaks (previously smashed into one + # run-on line). Assert on the echoed remote name, which git does not + # localize, rather than on translatable English error text. + assert "\n" in output + assert "no-such-remote" in output From d4e87515dc9debd7bb86fd342933bc9c7cb3d87e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 19:57:31 -0500 Subject: [PATCH 3/9] run(refactor[trim]): Default to verbatim output why: Trimming in the capture path corrupted whitespace-significant output. Returning verbatim by default makes the plain `.run(["diff"])` faithful and applyable, fixing the original bug without an opt-in. Bare-value reads opt in with `trim=True`. what: - Flip `trim` default from True to False (verbatim). - Rewrite the docstring around the verbatim default. --- src/libvcs/_internal/run.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/libvcs/_internal/run.py b/src/libvcs/_internal/run.py index d29df0e26..345ca7dde 100644 --- a/src/libvcs/_internal/run.py +++ b/src/libvcs/_internal/run.py @@ -149,7 +149,7 @@ def run( umask: int = -1, log_in_real_time: bool = False, check_returncode: bool = True, - trim: bool = True, + trim: bool = False, callback: ProgressCallbackProtocol | None = None, timeout: float | None = None, ) -> str: @@ -158,12 +158,12 @@ def run( Run 'args' in a shell and return the combined contents of stdout and stderr (Blocking). Throws an exception if the command exits non-zero. - Output is captured verbatim. When ``trim`` is True (the default) - :meth:`str.rstrip` removes trailing whitespace from the whole output, - preserving leading indentation, blank lines, and interior structure. Pass - ``trim=False`` for byte-faithful output -- for example a ``git diff`` - destined for ``git apply``, which requires the trailing newline, or - ``git cat-file blob`` whose contents must round-trip exactly. + Output is returned verbatim by default, so whitespace-significant output + round-trips exactly -- for example a ``git diff`` destined for + ``git apply``, which requires the trailing newline, or a ``git cat-file + blob`` whose contents must match byte-for-byte. Pass ``trim=True`` to strip + trailing whitespace from the whole output for convenient "bare value" reads + where a trailing newline is just noise (e.g. ``rev-parse HEAD``). Keyword arguments are passthrough to :class:`subprocess.Popen`. @@ -190,12 +190,12 @@ def run( code is different from 0. trim : bool - When True (default), strip trailing whitespace from the whole output - for the convenient "bare value" reads callers expect (e.g. - ``rev-parse HEAD``). When False, return the output verbatim, including - any trailing newline, so whitespace-significant output (diffs, blob - contents) round-trips exactly. On a non-zero exit the captured stderr - used for the error output is trimmed the same way. + When False (the default), return the output verbatim, including any + trailing newline, so whitespace-significant output (diffs, blob + contents) round-trips exactly. When True, strip trailing whitespace + from the whole output for the convenient "bare value" reads callers + expect (e.g. ``rev-parse HEAD``). The same policy applies to stderr + captured for a failed command's error output. callback : ProgressCallbackProtocol callback to return output as a command executes, accepts a function signature From 2c0643ede21b976a78382305e22a38057b48b2d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 19:57:31 -0500 Subject: [PATCH 4/9] sync/git(fix[get_revision]): Return a bare revision why: get_revision() is a scalar API downstream compares to bare SHAs; with verbatim run() it would carry a trailing newline. what: - Strip the rev-parse result so get_revision() stays bare. --- src/libvcs/sync/git.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py index 416ec8f7f..0a4d151b0 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -326,7 +326,9 @@ def from_pip_url(cls, pip_url: str, **kwargs: t.Any) -> GitSync: def get_revision(self) -> str: """Return current revision. Initial repositories return 'initial'.""" try: - return self.cmd.rev_parse(verify=True, args="HEAD", check_returncode=True) + return self.cmd.rev_parse( + verify=True, args="HEAD", check_returncode=True + ).strip() except exc.CommandError: return "initial" @@ -477,7 +479,7 @@ def update_repo( 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) @@ -543,7 +545,7 @@ def update_repo( 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 From 7d1cf5f385aa2a044a979b6210393516e7df12e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 19:57:31 -0500 Subject: [PATCH 5/9] cmd(feat[trim]): Add per-command trim opt-in; tidy doctests why: With run() verbatim by default, command methods return output with a trailing newline. Forwarding a `trim` flag lets callers opt into the bare value, and lets doctests show clean output without raw-string newline noise. what: - Forward a `trim` parameter from git/hg/svn command methods to run(). - Use `trim=True` in command doctests and drop the trailing newline. - Restore plain docstrings where the escaped newline is gone. --- src/libvcs/cmd/git.py | 254 +++++++++++++++++++++++++++++------------- src/libvcs/cmd/hg.py | 24 ++-- src/libvcs/cmd/svn.py | 79 +++++++++---- 3 files changed, 247 insertions(+), 110 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index fbad217e7..392bdbae5 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -57,7 +57,7 @@ def __init__( Subcommands: - >>> git.remotes.show() + >>> git.remotes.show(trim=True) 'origin' >>> git.remotes.add( @@ -65,10 +65,10 @@ def __init__( ... ) '' - >>> git.remotes.show() + >>> git.remotes.show(trim=True) 'my_remote\norigin' - >>> git.stash.save(message="Message") + >>> git.stash.save(message="Message", trim=True) 'No local changes to save' >>> git.submodule.init() @@ -78,7 +78,7 @@ def __init__( >>> git.remotes.get(remote_name='my_remote').remove() '' - >>> git.remotes.show() + >>> git.remotes.show(trim=True) 'origin' >>> git.stash.ls() @@ -651,6 +651,7 @@ def rebase( _quit: bool | None = None, # libvcs special behavior check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Reapply commit on top of another tip. @@ -661,19 +662,21 @@ def rebase( ---------- continue : bool Accepted via kwargs + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> git = Git(path=example_git_repo.path) >>> git_remote_repo = create_git_remote_repo() - >>> git.rebase() + >>> git.rebase(trim=True) 'Current branch master is up to date.' Declare upstream: >>> git = Git(path=example_git_repo.path) >>> git_remote_repo = create_git_remote_repo() - >>> git.rebase(upstream='origin') + >>> git.rebase(upstream='origin', trim=True) 'Current branch master is up to date.' >>> git.path.exists() True @@ -792,6 +795,7 @@ def rebase( return self.run( ["rebase", *local_flags, *required_flags], check_returncode=check_returncode, + trim=trim, ) def pull( @@ -886,6 +890,7 @@ def pull( # Pass-through to run log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Download from repo. Wraps `git pull `_. @@ -894,7 +899,7 @@ def pull( -------- >>> git = Git(path=example_git_repo.path) >>> git_remote_repo = create_git_remote_repo() - >>> git.pull() + >>> git.pull(trim=True) 'Already up to date.' Fetch via ref: @@ -1084,6 +1089,7 @@ def pull( ["pull", *local_flags, "--", *required_flags], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def init( @@ -1542,6 +1548,7 @@ def checkout( treeish: str | None = None, # libvcs special behavior check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Switch branches or checks out files. @@ -1576,12 +1583,14 @@ def checkout( new_branch : str start_point : str treeish : str + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> git = Git(path=example_git_repo.path) - >>> git.checkout() + >>> git.checkout(trim=True) "Your branch is up to date with 'origin/master'." >>> git.checkout(branch='origin/master', pathspec='.') @@ -1643,6 +1652,7 @@ def checkout( return self.run( ["checkout", *local_flags, *(["--", *pathspec] if pathspec else [])], check_returncode=check_returncode, + trim=trim, ) def status( @@ -1667,6 +1677,7 @@ def status( pathspec: StrOrBytesPath | list[StrOrBytesPath] | None = None, # libvcs special behavior check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Return status of working tree. @@ -1694,6 +1705,8 @@ def status( ignored_submodules : "untracked", "dirty", "all" pathspec : :attr:`libvcs._internal.types.StrOrBytesPath` or list :attr:`libvcs._internal.types.StrOrBytesPath` + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- @@ -1704,16 +1717,16 @@ def status( >>> pathlib.Path(example_git_repo.path / 'new_file.txt').touch() - >>> git.status(porcelain=True) + >>> git.status(porcelain=True, trim=True) '?? new_file.txt' - >>> git.status(porcelain='1') + >>> git.status(porcelain='1', trim=True) '?? new_file.txt' - >>> git.status(porcelain='2') + >>> git.status(porcelain='2', trim=True) '? new_file.txt' - >>> git.status(C=example_git_repo.path / '.git', porcelain='2') + >>> git.status(C=example_git_repo.path / '.git', porcelain='2', trim=True) '? new_file.txt' >>> git.status(porcelain=True, untracked_files="no") @@ -1777,6 +1790,7 @@ def status( return self.run( ["status", *local_flags, *(["--", *pathspec] if pathspec else [])], check_returncode=check_returncode, + trim=trim, ) def config( @@ -1814,6 +1828,7 @@ def config( add: bool | None = None, # libvcs special behavior check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Get and set repo configuration. @@ -1851,6 +1866,8 @@ def config( no_includes : Optional[bool] includes : Optional[bool] add : Optional[bool] + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- @@ -1862,7 +1879,7 @@ def config( >>> git.config(_list=True) '...user.email=...' - >>> git.config(get='color.diff') + >>> git.config(get='color.diff', trim=True) 'auto' """ local_flags: list[str] = [] @@ -1963,6 +1980,7 @@ def config( return self.run( ["config", *local_flags], check_returncode=check_returncode, + trim=trim, ) def version( @@ -2049,6 +2067,7 @@ def rev_parse( return self.run( ["rev-parse", *local_flags], check_returncode=check_returncode, + **kwargs, ) def rev_list( @@ -2136,7 +2155,7 @@ def rev_list( >>> git.rev_list(commit="HEAD") '...' - >>> git.run(['commit', '--allow-empty', '--message=Moo']) + >>> git.run(['commit', '--allow-empty', '--message=Moo'], trim=True) '[master ...] Moo' >>> git.rev_list(commit="HEAD", max_count=1) @@ -2252,6 +2271,7 @@ def rev_list( ], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + **kwargs, ) def symbolic_ref( @@ -2265,6 +2285,7 @@ def symbolic_ref( quiet: bool | None = None, # libvcs special behavior check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Return symbolic-ref. @@ -2275,10 +2296,10 @@ def symbolic_ref( -------- >>> git = Git(path=example_git_repo.path) - >>> git.symbolic_ref(name="test") + >>> git.symbolic_ref(name="test", trim=True) 'fatal: ref test is not a symbolic ref' - >>> git.symbolic_ref(name="test") + >>> git.symbolic_ref(name="test", trim=True) 'fatal: ref test is not a symbolic ref' """ required_flags: list[str] = [name] @@ -2297,6 +2318,7 @@ def symbolic_ref( return self.run( ["symbolic-ref", *required_flags, *local_flags], check_returncode=check_returncode, + trim=trim, ) def show_ref( @@ -2312,6 +2334,7 @@ def show_ref( abbrev: str | bool | None = None, # libvcs special behavior check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: r"""show-ref. Wraps `git show-ref `_. @@ -2329,10 +2352,10 @@ def show_ref( >>> git.show_ref(pattern='master', head=True) '...' - >>> git.show_ref(pattern='HEAD', verify=True) + >>> git.show_ref(pattern='HEAD', verify=True, trim=True) '... HEAD' - >>> git.show_ref(pattern='master', dereference=True) + >>> git.show_ref(pattern='master', dereference=True, trim=True) '... refs/heads/master\n... refs/remotes/origin/master' >>> git.show_ref(pattern='HEAD', tags=True) @@ -2374,6 +2397,7 @@ def show_ref( *(["--", *pattern_flags] if pattern_flags else []), ], check_returncode=check_returncode, + trim=trim, ) @@ -2408,7 +2432,7 @@ def __init__(self, *, path: StrPath, cmd: Git | None = None) -> None: >>> GitSubmoduleCmd(path=tmp_path) - >>> GitSubmoduleCmd(path=tmp_path).run(quiet=True) + >>> GitSubmoduleCmd(path=tmp_path).run(quiet=True, trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitSubmoduleCmd(path=example_git_repo.path).run(quiet=True) @@ -2974,7 +2998,7 @@ def __init__( >>> GitSubmoduleManager(path=tmp_path) - >>> GitSubmoduleManager(path=tmp_path).run('status') + >>> GitSubmoduleManager(path=tmp_path).run('status', trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitSubmoduleManager(path=example_git_repo.path).run('status') @@ -3677,7 +3701,7 @@ def __init__( >>> GitRemoteCmd( ... path=tmp_path, ... remote_name='origin', - ... ).run(verbose=True) + ... ).run(verbose=True, trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitRemoteCmd( @@ -3723,7 +3747,7 @@ def run( >>> GitRemoteCmd( ... path=example_git_repo.path, ... remote_name='master', - ... ).run() + ... ).run(trim=True) 'origin' >>> GitRemoteCmd( ... path=example_git_repo.path, @@ -3768,7 +3792,7 @@ def rename( >>> GitRemoteCmd( ... path=example_git_repo.path, ... remote_name='origin', - ... ).run() + ... ).run(trim=True) 'new_name' """ local_flags: list[str] = [] @@ -3957,7 +3981,7 @@ def set_url( log_in_real_time: bool = False, check_returncode: bool | None = None, ) -> str: - """Git remote set-url. + r"""Git remote set-url. Examples -------- @@ -3992,14 +4016,14 @@ def set_url( ... path=example_git_repo.path, ... remote_name='origin' ... ).get_url() - >>> GitRemoteCmd( + >>> 'fatal' in GitRemoteCmd( ... path=example_git_repo.path, ... remote_name='origin' ... ).set_url( ... url=current_url, ... delete=True ... ) - 'fatal: Will not delete all non-push URLs' + True """ local_flags: list[str] = [] @@ -4211,12 +4235,12 @@ def __init__( >>> GitRemoteManager(path=tmp_path) - >>> GitRemoteManager(path=tmp_path).run(check_returncode=False) + >>> GitRemoteManager(path=tmp_path).run(check_returncode=False, trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitRemoteManager( ... path=example_git_repo.path - ... ).run() + ... ).run(trim=True) 'origin' """ #: Directory to check out @@ -4248,7 +4272,7 @@ def run( Examples -------- - >>> GitRemoteManager(path=example_git_repo.path).run() + >>> GitRemoteManager(path=example_git_repo.path).run(trim=True) 'origin' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -4319,12 +4343,13 @@ def show( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git remote show. Examples -------- - >>> GitRemoteManager(path=example_git_repo.path).show() + >>> GitRemoteManager(path=example_git_repo.path).show(trim=True) 'origin' For the example below, add a remote: @@ -4354,18 +4379,20 @@ def show( local_flags=[*local_flags, "--", *required_flags], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) - def _ls(self) -> str: + def _ls(self, trim: bool = False) -> str: r"""List remotes (raw output). Examples -------- - >>> GitRemoteManager(path=example_git_repo.path)._ls() + >>> GitRemoteManager(path=example_git_repo.path)._ls(trim=True) 'origin\tfile:///... (fetch)\norigin\tfile:///... (push)' """ return self.run( "--verbose", + trim=trim, ) def ls(self) -> QueryList[GitRemoteCmd]: @@ -4579,6 +4606,7 @@ def show( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git stash show for this stash entry. @@ -4590,13 +4618,15 @@ def show( Show patch (-p) include_untracked : Include untracked files (-u) + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitStashEntryCmd( ... path=example_git_repo.path, ... index=0, - ... ).show() + ... ).show(trim=True) 'error: stash@{0} is not a valid reference' """ local_flags: list[str] = [] @@ -4615,6 +4645,7 @@ def show( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def apply( @@ -4625,6 +4656,7 @@ def apply( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git stash apply for this stash entry. @@ -4636,13 +4668,15 @@ def apply( Try to reinstate not only the working tree but also the index (--index) quiet : Suppress output (-q) + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitStashEntryCmd( ... path=example_git_repo.path, ... index=0, - ... ).apply() + ... ).apply(trim=True) 'error: stash@{0} is not a valid reference' """ local_flags: list[str] = [] @@ -4659,6 +4693,7 @@ def apply( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def pop( @@ -4669,6 +4704,7 @@ def pop( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git stash pop for this stash entry. @@ -4680,13 +4716,15 @@ def pop( Try to reinstate not only the working tree but also the index (--index) quiet : Suppress output (-q) + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitStashEntryCmd( ... path=example_git_repo.path, ... index=0, - ... ).pop() + ... ).pop(trim=True) 'error: stash@{0} is not a valid reference' """ local_flags: list[str] = [] @@ -4703,6 +4741,7 @@ def pop( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def drop( @@ -4712,6 +4751,7 @@ def drop( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git stash drop for this stash entry. @@ -4721,13 +4761,15 @@ def drop( ---------- quiet : Suppress output (-q) + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitStashEntryCmd( ... path=example_git_repo.path, ... index=0, - ... ).drop() + ... ).drop(trim=True) 'error: stash@{0} is not a valid reference' """ local_flags: list[str] = [] @@ -4742,6 +4784,7 @@ def drop( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def create_branch( @@ -4751,6 +4794,7 @@ def create_branch( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git stash branch for this stash entry. @@ -4760,13 +4804,15 @@ def create_branch( ---------- branch_name : Name of the branch to create + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitStashEntryCmd( ... path=example_git_repo.path, ... index=0, - ... ).create_branch('new-branch') + ... ).create_branch('new-branch', trim=True) 'error: stash@{0} is not a valid reference' """ local_flags: list[str] = [branch_name, self.stash_ref] @@ -4776,6 +4822,7 @@ def create_branch( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) @@ -4795,7 +4842,7 @@ def __init__(self, *, path: StrPath, cmd: Git | None = None) -> None: >>> GitStashCmd(path=tmp_path) - >>> GitStashCmd(path=tmp_path).run(quiet=True) + >>> GitStashCmd(path=tmp_path).run(quiet=True, trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitStashCmd(path=example_git_repo.path).run(quiet=True) @@ -4831,7 +4878,7 @@ def run( Examples -------- - >>> GitStashCmd(path=example_git_repo.path).run() + >>> GitStashCmd(path=example_git_repo.path).run(trim=True) 'No local changes to save' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -4845,6 +4892,7 @@ def run( ["stash", *local_flags], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + **kwargs, ) def ls( @@ -4876,6 +4924,7 @@ def push( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Push changes to the stash. @@ -4890,13 +4939,15 @@ def push( Interactively select hunks to stash. staged : Stash only staged changes. + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- - >>> GitStashCmd(path=example_git_repo.path).push() + >>> GitStashCmd(path=example_git_repo.path).push(trim=True) 'No local changes to save' - >>> GitStashCmd(path=example_git_repo.path).push(path='.') + >>> GitStashCmd(path=example_git_repo.path).push(path='.', trim=True) 'No local changes to save' """ local_flags: list[str] = [] @@ -4917,6 +4968,7 @@ def push( local_flags=[*local_flags, "--", *required_flags], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def pop( @@ -4928,25 +4980,26 @@ def pop( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Git stash pop. Examples -------- - >>> GitStashCmd(path=example_git_repo.path).pop() + >>> GitStashCmd(path=example_git_repo.path).pop(trim=True) 'No stash entries found.' - >>> GitStashCmd(path=example_git_repo.path).pop(stash=0) + >>> GitStashCmd(path=example_git_repo.path).pop(stash=0, trim=True) 'error: stash@{0} is not a valid reference' - >>> GitStashCmd(path=example_git_repo.path).pop(stash=1, index=True) + >>> GitStashCmd(path=example_git_repo.path).pop(stash=1, index=True, trim=True) 'error: stash@{1} is not a valid reference' - >>> GitStashCmd(path=example_git_repo.path).pop(stash=1, quiet=True) + >>> GitStashCmd(path=example_git_repo.path).pop(stash=1, quiet=True, trim=True) 'error: stash@{1} is not a valid reference' - >>> GitStashCmd(path=example_git_repo.path).push(path='.') + >>> GitStashCmd(path=example_git_repo.path).push(path='.', trim=True) 'No local changes to save' """ local_flags: list[str] = [] @@ -4963,6 +5016,7 @@ def pop( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def save( @@ -4978,16 +5032,17 @@ def save( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Git stash save. Examples -------- - >>> GitStashCmd(path=example_git_repo.path).save() + >>> GitStashCmd(path=example_git_repo.path).save(trim=True) 'No local changes to save' - >>> GitStashCmd(path=example_git_repo.path).save(message="Message") + >>> GitStashCmd(path=example_git_repo.path).save(message="Message", trim=True) 'No local changes to save' """ local_flags: list[str] = [] @@ -5014,6 +5069,7 @@ def save( local_flags=local_flags + stash_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) @@ -5038,12 +5094,12 @@ def __init__( >>> GitStashManager(path=tmp_path) - >>> GitStashManager(path=tmp_path).run() + >>> GitStashManager(path=tmp_path).run(trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitStashManager( ... path=example_git_repo.path - ... ).run() + ... ).run(trim=True) 'No local changes to save' """ #: Directory to check out @@ -5076,7 +5132,7 @@ def run( Examples -------- - >>> GitStashManager(path=example_git_repo.path).run() + >>> GitStashManager(path=example_git_repo.path).run(trim=True) 'No local changes to save' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -5107,6 +5163,7 @@ def push( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Git stash push. @@ -5130,13 +5187,15 @@ def push( Include ignored files (-a) quiet : Suppress output (-q) + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- - >>> GitStashManager(path=example_git_repo.path).push() + >>> GitStashManager(path=example_git_repo.path).push(trim=True) 'No local changes to save' - >>> GitStashManager(path=example_git_repo.path).push(message='WIP') + >>> GitStashManager(path=example_git_repo.path).push(message='WIP', trim=True) 'No local changes to save' """ local_flags: list[str] = [] @@ -5171,6 +5230,7 @@ def push( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def clear( @@ -5332,13 +5392,13 @@ def __init__( >>> GitBranchCmd( ... path=tmp_path, ... branch_name='master' - ... ).run(quiet=True) + ... ).run(quiet=True, trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='master' - ... ).run(quiet=True) + ... ).run(quiet=True, trim=True) '* master' """ #: Directory to check out @@ -5375,7 +5435,7 @@ def run( >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='master' - ... ).run() + ... ).run(trim=True) '* master' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -5390,7 +5450,7 @@ def run( **kwargs, ) - def checkout(self) -> str: + def checkout(self, trim: bool = False) -> str: """Git branch checkout. Examples @@ -5398,7 +5458,7 @@ def checkout(self) -> str: >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='master' - ... ).checkout() + ... ).checkout(trim=True) "Your branch is up to date with 'origin/master'." """ return self.cmd.run( @@ -5406,12 +5466,14 @@ def checkout(self) -> str: "checkout", *[self.branch_name], ], + trim=trim, ) def create( self, *, checkout: bool = False, + trim: bool = False, ) -> str: """Create a git branch. @@ -5420,18 +5482,21 @@ def create( checkout : If True, also checkout the newly created branch. Defaults to False (create only, don't switch HEAD). + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='master' - ... ).create() + ... ).create(trim=True) "fatal: a branch named 'master' already exists" """ result = self.cmd.run( ["branch", self.branch_name], check_returncode=False, + trim=trim, ) if checkout and "fatal" not in result.lower(): self.cmd.run(["checkout", self.branch_name]) @@ -5444,6 +5509,7 @@ def delete( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Delete this git branch. @@ -5451,13 +5517,15 @@ def delete( ---------- force : Use ``-D`` instead of ``-d`` to force deletion. + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='nonexistent' - ... ).delete() + ... ).delete(trim=True) "error: branch 'nonexistent' not found" """ flag = "-D" if force else "-d" @@ -5465,6 +5533,7 @@ def delete( ["branch", flag, self.branch_name], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def rename( @@ -5540,6 +5609,7 @@ def set_upstream( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Set the upstream (tracking) branch. @@ -5547,19 +5617,22 @@ def set_upstream( ---------- upstream : The upstream branch in format ``remote/branch`` (e.g., ``origin/main``). + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='master' - ... ).set_upstream('origin/master') + ... ).set_upstream('origin/master', trim=True) "branch 'master' set up to track 'origin/master'." """ return self.cmd.run( ["branch", f"--set-upstream-to={upstream}", self.branch_name], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def unset_upstream( @@ -5592,6 +5665,7 @@ def track( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Create branch tracking a remote branch. @@ -5601,6 +5675,8 @@ def track( ---------- remote_branch : Remote branch to track (e.g., 'origin/main'). + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- @@ -5610,13 +5686,14 @@ def track( >>> GitBranchCmd( ... path=example_git_repo.path, ... branch_name='tracking-branch' - ... ).track('origin/master') + ... ).track('origin/master', trim=True) "branch 'tracking-branch' set up to track 'origin/master'." """ return self.cmd.run( ["branch", "-t", self.branch_name, remote_branch], check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) @@ -5643,11 +5720,11 @@ def __init__( >>> GitBranchManager(path=tmp_path) - >>> GitBranchManager(path=tmp_path).run(quiet=True) + >>> GitBranchManager(path=tmp_path).run(quiet=True, trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitBranchManager( - ... path=example_git_repo.path).run(quiet=True) + ... path=example_git_repo.path).run(quiet=True, trim=True) '* master' """ #: Directory to check out @@ -5679,7 +5756,7 @@ def run( Examples -------- - >>> GitBranchManager(path=example_git_repo.path).run() + >>> GitBranchManager(path=example_git_repo.path).run(trim=True) '* master' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -5694,12 +5771,13 @@ def run( **kwargs, ) - def checkout(self, *, branch: str) -> str: + def checkout(self, *, branch: str, trim: bool = False) -> str: """Git branch checkout. Examples -------- - >>> GitBranchManager(path=example_git_repo.path).checkout(branch='master') + >>> branch_mgr = GitBranchManager(path=example_git_repo.path) + >>> branch_mgr.checkout(branch='master', trim=True) "Your branch is up to date with 'origin/master'." """ return self.cmd.run( @@ -5707,6 +5785,7 @@ def checkout(self, *, branch: str) -> str: "checkout", *[branch], ], + trim=trim, ) def create( @@ -5714,6 +5793,7 @@ def create( *, branch: str, checkout: bool = False, + trim: bool = False, ) -> str: """Create a git branch. @@ -5724,15 +5804,19 @@ def create( checkout : If True, also checkout the newly created branch. Defaults to False (create only, don't switch HEAD). + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- - >>> GitBranchManager(path=example_git_repo.path).create(branch='master') + >>> branch_mgr = GitBranchManager(path=example_git_repo.path) + >>> branch_mgr.create(branch='master', trim=True) "fatal: a branch named 'master' already exists" """ result = self.cmd.run( ["branch", branch], check_returncode=False, + trim=trim, ) if checkout and "fatal" not in result.lower(): self.cmd.run(["checkout", branch]) @@ -6086,7 +6170,7 @@ def __init__( >>> GitTagManager(path=tmp_path) - >>> GitTagManager(path=tmp_path).run() + >>> GitTagManager(path=tmp_path).run(trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> mgr = GitTagManager(path=example_git_repo.path) @@ -6486,6 +6570,7 @@ def remove( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Remove this worktree. @@ -6493,13 +6578,15 @@ def remove( ---------- force : Force removal even if worktree is dirty or locked. + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitWorktreeCmd( ... path=example_git_repo.path, ... worktree_path='/tmp/nonexistent-worktree', - ... ).remove() + ... ).remove(trim=True) "fatal: '/tmp/nonexistent-worktree' is not a working tree" """ local_flags: list[str] = [] @@ -6514,6 +6601,7 @@ def remove( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def lock( @@ -6523,6 +6611,7 @@ def lock( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Lock this worktree. @@ -6530,13 +6619,15 @@ def lock( ---------- reason : Reason for locking the worktree. + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitWorktreeCmd( ... path=example_git_repo.path, ... worktree_path='/tmp/nonexistent-worktree', - ... ).lock() + ... ).lock(trim=True) "fatal: '/tmp/nonexistent-worktree' is not a working tree" """ local_flags: list[str] = [] @@ -6551,6 +6642,7 @@ def lock( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def unlock( @@ -6559,6 +6651,7 @@ def unlock( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Unlock this worktree. @@ -6567,7 +6660,7 @@ def unlock( >>> GitWorktreeCmd( ... path=example_git_repo.path, ... worktree_path='/tmp/nonexistent-worktree', - ... ).unlock() + ... ).unlock(trim=True) "fatal: '/tmp/nonexistent-worktree' is not a working tree" """ local_flags: list[str] = [self.worktree_path] @@ -6577,6 +6670,7 @@ def unlock( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def move( @@ -6587,6 +6681,7 @@ def move( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Move this worktree to a new location. @@ -6596,13 +6691,15 @@ def move( New path for the worktree. force : Force move even if worktree is dirty or locked. + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> GitWorktreeCmd( ... path=example_git_repo.path, ... worktree_path='/tmp/nonexistent-worktree', - ... ).move('/tmp/new-worktree') + ... ).move('/tmp/new-worktree', trim=True) "fatal: '/tmp/nonexistent-worktree' is not a working tree" """ local_flags: list[str] = [] @@ -6617,6 +6714,7 @@ def move( local_flags=local_flags, check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def repair( @@ -6668,7 +6766,7 @@ def __init__( >>> GitWorktreeManager(path=tmp_path) - >>> GitWorktreeManager(path=tmp_path).run('list') + >>> GitWorktreeManager(path=tmp_path).run('list', trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> len(GitWorktreeManager(path=example_git_repo.path).run('list')) > 0 @@ -7287,7 +7385,7 @@ def __init__( >>> GitNotesManager(path=tmp_path) - >>> GitNotesManager(path=tmp_path).run('list') + >>> GitNotesManager(path=tmp_path).run('list', trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitNotesManager(path=example_git_repo.path).run('list') @@ -7529,18 +7627,20 @@ def get_ref( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, + trim: bool = False, ) -> str: """Get the current notes ref. Examples -------- - >>> GitNotesManager(path=example_git_repo.path).get_ref() + >>> GitNotesManager(path=example_git_repo.path).get_ref(trim=True) 'refs/notes/commits' """ return self.run( "get-ref", check_returncode=check_returncode, log_in_real_time=log_in_real_time, + trim=trim, ) def _ls(self) -> list[tuple[str, str]]: @@ -7834,7 +7934,7 @@ def __init__( >>> GitReflogManager(path=tmp_path) - >>> GitReflogManager(path=tmp_path).run('show') + >>> GitReflogManager(path=tmp_path).run('show', trim=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> len(GitReflogManager(path=example_git_repo.path).run('show')) > 0 diff --git a/src/libvcs/cmd/hg.py b/src/libvcs/cmd/hg.py index 2b40dc209..d9b5d93e2 100644 --- a/src/libvcs/cmd/hg.py +++ b/src/libvcs/cmd/hg.py @@ -281,7 +281,7 @@ def update( # libvcs special behavior check_returncode: bool | None = True, *args: object, - **kwargs: object, + **kwargs: t.Any, ) -> str: """Update working directory. @@ -293,7 +293,7 @@ def update( >>> hg_remote_repo = create_hg_remote_repo() >>> hg.clone(url=f'file://{hg_remote_repo}') 'updating to branch default...1 files updated, 0 files merged, ...' - >>> hg.update() + >>> hg.update(trim=True) '0 files updated, 0 files merged, 0 files removed, 0 files unresolved' """ local_flags: list[str] = [] @@ -303,7 +303,9 @@ def update( if verbose: local_flags.append("--verbose") - return self.run(["update", *local_flags], check_returncode=check_returncode) + return self.run( + ["update", *local_flags], check_returncode=check_returncode, **kwargs + ) def pull( self, @@ -313,9 +315,9 @@ def pull( # libvcs special behavior check_returncode: bool | None = True, *args: object, - **kwargs: object, + **kwargs: t.Any, ) -> str: - """Update working directory. + r"""Pull changes from a remote repository. Wraps `hg pull `_. @@ -325,10 +327,10 @@ def pull( >>> hg_remote_repo = create_hg_remote_repo() >>> hg.clone(url=f'file://{hg_remote_repo}') 'updating to branch default...1 files updated, 0 files merged, ...' - >>> hg.pull() - 'pulling from ...searching for changes...no changes found' - >>> hg.pull(update=True) - 'pulling from ...searching for changes...no changes found' + >>> hg.pull(trim=True) # doctest: +ELLIPSIS + 'pulling from ...\nsearching for changes\nno changes found' + >>> hg.pull(update=True, trim=True) # doctest: +ELLIPSIS + 'pulling from ...\nsearching for changes\nno changes found' """ local_flags: list[str] = [] @@ -339,4 +341,6 @@ def pull( if update: local_flags.append("--update") - return self.run(["pull", *local_flags], check_returncode=check_returncode) + return self.run( + ["pull", *local_flags], check_returncode=check_returncode, **kwargs + ) diff --git a/src/libvcs/cmd/svn.py b/src/libvcs/cmd/svn.py index fc1787df5..cd22bfc8e 100644 --- a/src/libvcs/cmd/svn.py +++ b/src/libvcs/cmd/svn.py @@ -248,6 +248,7 @@ def add( auto_props: bool | None = None, no_auto_props: bool | None = None, parents: bool | None = None, + trim: bool = False, ) -> str: """Stage an unversioned file to be pushed to repository next commit. @@ -272,6 +273,8 @@ def add( `--no-auto-props` parents : `--parents` + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- @@ -281,8 +284,8 @@ def add( >>> new_file = tmp_path / 'new.txt' >>> new_file.write_text('example text', encoding="utf-8") 12 - >>> svn.add(path=new_file) - 'A new.txt' + >>> svn.add(path=new_file, trim=True) + 'A new.txt' """ local_flags: list[str] = [] @@ -302,12 +305,13 @@ def add( if parents is True: local_flags.append("--parents") - return self.run(["add", *local_flags]) + return self.run(["add", *local_flags], trim=trim) def auth( self, remove: str | None = None, show_passwords: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Manage stored authentication credentials. @@ -321,10 +325,12 @@ def auth( Remove matching auth credentials show_passwords : bool, optional Show cached passwords + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- - >>> Svn(path=tmp_path).auth() + >>> Svn(path=tmp_path).auth(trim=True) "Credentials cache in '...' is empty" """ local_flags: list[str] = [] @@ -334,7 +340,7 @@ def auth( if show_passwords is True: local_flags.append("--show-passwords") - return self.run(["auth", *local_flags]) + return self.run(["auth", *local_flags], trim=trim) def blame( self, @@ -347,6 +353,7 @@ def blame( incremental: bool | None = None, xml: bool | None = None, extensions: str | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Show authorship for file line-by-line. @@ -372,6 +379,8 @@ def blame( Diff or blame tool (pass raw args). force : bool, optional force operation to run + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- @@ -382,11 +391,11 @@ def blame( >>> new_file = tmp_path / 'new.txt' >>> new_file.write_text('example text', encoding="utf-8") 12 - >>> svn.add(path=new_file) - 'A new.txt' + >>> svn.add(path=new_file, trim=True) + 'A new.txt' >>> svn.commit(path=new_file, message='My new commit') '...' - >>> svn.blame('new.txt') + >>> svn.blame('new.txt', trim=True) ' 4 ... example text' """ local_flags: list[str] = [str(target)] @@ -406,7 +415,7 @@ def blame( if force is True: local_flags.append("--force") - return self.run(["blame", *local_flags]) + return self.run(["blame", *local_flags], trim=trim) def cat(self, *args: t.Any, **kwargs: t.Any) -> str: """Output contents of files from working copy or repository URLs. @@ -451,6 +460,7 @@ def commit( force_log: bool | None = None, keep_changelists: bool | None = None, include_externals: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Push changes from working copy to SVN repo. @@ -470,6 +480,8 @@ def commit( `--keep_changelists`, don't delete changelists after commit force_log : `--force-log`, Ignore already versioned paths + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- @@ -479,9 +491,9 @@ def commit( >>> new_file = tmp_path / 'new.txt' >>> new_file.write_text('example text', encoding="utf-8") 12 - >>> svn.add(path=new_file) - 'A new.txt' - >>> svn.commit(path=new_file, message='My new commit') + >>> svn.add(path=new_file, trim=True) + 'A new.txt' + >>> svn.commit(path=new_file, message='My new commit', trim=True) 'Adding new.txt...Transmitting file data...Committed revision 4.' """ local_flags: list[str] = [] @@ -504,7 +516,7 @@ def commit( if include_externals is True: local_flags.append("--include-externals") - return self.run(["commit", *local_flags]) + return self.run(["commit", *local_flags], trim=trim) def copy(self, *args: t.Any, **kwargs: t.Any) -> str: """Copy file or dir in this SVN working copy or repo. @@ -637,6 +649,7 @@ def lock( self, targets: pathlib.Path | None = None, force: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Lock path or URLs for working copy or repository. @@ -644,12 +657,17 @@ def lock( Wraps `svn lock `_. + Parameters + ---------- + trim : bool, default: False + Strip trailing whitespace/newline from command output. + Examples -------- >>> svn = Svn(path=tmp_path) >>> svn.checkout(url=f'file://{create_svn_remote_repo()}') '...Checked out revision ...' - >>> svn.lock(targets='samplepickle') + >>> svn.lock(targets='samplepickle', trim=True) "'samplepickle' locked by user '...'." """ local_flags: list[str] = [] @@ -663,7 +681,7 @@ def lock( if force: local_flags.append("--force") - return self.run(["lock", *local_flags]) + return self.run(["lock", *local_flags], trim=trim) def log(self, *args: t.Any, **kwargs: t.Any) -> str: """Show logs from repository. @@ -764,6 +782,7 @@ def propset( value_path: StrPath | None = None, target: StrOrBytesPath | None = None, *args: t.Any, + trim: bool = False, **kwargs: t.Any, ) -> str: """Set property for this SVN working copy or a remote revision. @@ -779,13 +798,15 @@ def propset( propname value_path : VALFILE + trim : bool, default: False + Strip trailing whitespace/newline from command output. Examples -------- >>> svn = Svn(path=tmp_path) >>> svn.checkout(url=f'file://{create_svn_remote_repo()}') '...Checked out revision ...' - >>> svn.propset(name="my_prop", value="value", path=".") + >>> svn.propset(name="my_prop", value="value", path=".", trim=True) "property 'my_prop' set on '.'" """ local_flags: list[str] = [name, *args] @@ -803,7 +824,7 @@ def propset( elif target is not None: local_flags.append(str(target)) - return self.run(["propset", *local_flags]) + return self.run(["propset", *local_flags], trim=trim) def relocate(self, *, to_path: StrPath, **kwargs: t.Any) -> str: """Set the SVN repository URL for this working copy. @@ -932,6 +953,7 @@ def revert( targets: pathlib.Path | None = None, depth: DepthLiteral = None, force: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Revert any changes to this SVN working copy. @@ -939,6 +961,11 @@ def revert( Wraps `svn revert `_. + Parameters + ---------- + trim : bool, default: False + Strip trailing whitespace/newline from command output. + Examples -------- >>> svn = Svn(path=tmp_path) @@ -947,8 +974,8 @@ def revert( >>> new_file = tmp_path / 'new.txt' >>> new_file.write_text('example text', encoding="utf-8") 12 - >>> svn.add(path=new_file) - 'A new.txt' + >>> svn.add(path=new_file, trim=True) + 'A new.txt' >>> svn.commit(path=new_file, message='My new commit') '...' >>> svn.revert(path=new_file) @@ -979,7 +1006,7 @@ def revert( if force is not None: local_flags.append("--force") - return self.run(["revert", *local_flags]) + return self.run(["revert", *local_flags], trim=trim) def status(self, *args: t.Any, **kwargs: t.Any) -> str: """Return status of this SVN working copy. @@ -1048,6 +1075,7 @@ def unlock( self, targets: pathlib.Path | None = None, force: bool | None = None, + trim: bool = False, **kwargs: t.Any, ) -> str: """Unlock path or URL reserved by another user. @@ -1055,14 +1083,19 @@ def unlock( Wraps `svn unlock `_. + Parameters + ---------- + trim : bool, default: False + Strip trailing whitespace/newline from command output. + Examples -------- >>> svn = Svn(path=tmp_path) >>> svn.checkout(url=f'file://{create_svn_remote_repo()}') '...Checked out revision ...' - >>> svn.lock(targets='samplepickle') + >>> svn.lock(targets='samplepickle', trim=True) "'samplepickle' locked by user '...'." - >>> svn.unlock(targets='samplepickle') + >>> svn.unlock(targets='samplepickle', trim=True) "'samplepickle' unlocked." """ local_flags: list[str] = [] @@ -1076,7 +1109,7 @@ def unlock( if force: local_flags.append("--force") - return self.run(["unlock", *local_flags]) + return self.run(["unlock", *local_flags], trim=trim) def update( self, From 8c4ef471afbdb4679d1c2874f4ae9625965acc87 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 19:57:31 -0500 Subject: [PATCH 6/9] tests(git): Adapt to verbatim run() output why: Tests that read scalars via the low-level run() or thin cmd wrappers relied on the old implicit trim. what: - Strip rev-parse / symbolic-ref / is-shallow / version reads. - Invert the default-behavior test to assert verbatim output. --- tests/cmd/test_git.py | 33 +++++++++++++++++---------------- tests/sync/test_git.py | 10 +++++----- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 07914c8d8..c5288f92a 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -695,7 +695,7 @@ def test_branch_cmd_create_checkout_parameter( branch_name = f"test-create-{test_id}" # Record current branch before creating - current_before = git_repo.cmd.symbolic_ref(name="HEAD", short=True) + current_before = git_repo.cmd.symbolic_ref(name="HEAD", short=True).strip() # Create branch using GitBranchCmd branch_cmd = git.GitBranchCmd(path=git_repo.path, branch_name=branch_name) @@ -710,7 +710,7 @@ def test_branch_cmd_create_checkout_parameter( assert branch_name in branch_names # Check if HEAD switched - current_after = git_repo.cmd.symbolic_ref(name="HEAD", short=True) + current_after = git_repo.cmd.symbolic_ref(name="HEAD", short=True).strip() if expect_switch: assert current_after == branch_name else: @@ -736,7 +736,7 @@ def test_branch_manager_create_checkout_parameter( branch_name = f"test-mgr-create-{test_id}" # Record current branch before creating - current_before = git_repo.cmd.symbolic_ref(name="HEAD", short=True) + current_before = git_repo.cmd.symbolic_ref(name="HEAD", short=True).strip() # Create branch using GitBranchManager result = git_repo.cmd.branches.create(branch=branch_name, checkout=checkout) @@ -750,7 +750,7 @@ def test_branch_manager_create_checkout_parameter( assert branch_name in branch_names # Check if HEAD switched - current_after = git_repo.cmd.symbolic_ref(name="HEAD", short=True) + current_after = git_repo.cmd.symbolic_ref(name="HEAD", short=True).strip() if expect_switch: assert current_after == branch_name else: @@ -1894,7 +1894,7 @@ def test_notes_get(git_repo: GitSync) -> None: git_repo.cmd.notes.add(message="Test note for get", force=True) # Get the HEAD revision - head_sha = git_repo.cmd.rev_parse(args="HEAD") + head_sha = git_repo.cmd.rev_parse(args="HEAD").strip() # Get the note by object_sha note = git_repo.cmd.notes.get(object_sha=head_sha) @@ -1920,7 +1920,7 @@ def test_notes_show(git_repo: GitSync) -> None: git_repo.cmd.notes.add(message=note_message, force=True) # Get the note - head_sha = git_repo.cmd.rev_parse(args="HEAD") + head_sha = git_repo.cmd.rev_parse(args="HEAD").strip() note = git_repo.cmd.notes.get(object_sha=head_sha) assert note is not None @@ -1936,7 +1936,7 @@ def test_notes_append(git_repo: GitSync) -> None: git_repo.cmd.notes.add(message=initial_message, force=True) # Get the note - head_sha = git_repo.cmd.rev_parse(args="HEAD") + head_sha = git_repo.cmd.rev_parse(args="HEAD").strip() note = git_repo.cmd.notes.get(object_sha=head_sha) assert note is not None @@ -1956,7 +1956,7 @@ def test_notes_remove(git_repo: GitSync) -> None: git_repo.cmd.notes.add(message="Note to be removed", force=True) # Get the note - head_sha = git_repo.cmd.rev_parse(args="HEAD") + head_sha = git_repo.cmd.rev_parse(args="HEAD").strip() note = git_repo.cmd.notes.get(object_sha=head_sha) assert note is not None @@ -1998,7 +1998,7 @@ def test_notes_edit(git_repo: GitSync, tmp_path: pathlib.Path) -> None: git_repo.cmd.notes.add(message="Initial note for edit test", force=True) # Get the note - head_sha = git_repo.cmd.rev_parse(args="HEAD") + head_sha = git_repo.cmd.rev_parse(args="HEAD").strip() note = git_repo.cmd.notes.get(object_sha=head_sha) assert note is not None @@ -2019,14 +2019,14 @@ def test_notes_copy(git_repo: GitSync) -> None: git_repo.cmd.run(["commit", "-m", "Commit for copy note test"]) # Get the new commit SHA - new_commit_sha = git_repo.cmd.rev_parse(args="HEAD") + new_commit_sha = git_repo.cmd.rev_parse(args="HEAD").strip() # Checkout previous commit to add note there git_repo.cmd.run(["checkout", "HEAD~1"]) git_repo.cmd.notes.add(message="Note to copy", force=True) # Get the note and copy to new commit - old_commit_sha = git_repo.cmd.rev_parse(args="HEAD") + old_commit_sha = git_repo.cmd.rev_parse(args="HEAD").strip() note = git_repo.cmd.notes.get(object_sha=old_commit_sha) assert note is not None @@ -2760,12 +2760,13 @@ def test_run_trim_false_preserves_blob(git_repo: GitSync) -> None: assert blob == base -def test_run_default_trims_trailing_newline(git_repo: GitSync) -> None: - """Default run() keeps the no-trailing-newline contract callers rely on.""" - sha = git_repo.cmd.run(["rev-parse", "HEAD"]) +def test_run_default_preserves_trailing_newline(git_repo: GitSync) -> None: + """Default run() returns output verbatim, including the trailing newline.""" + verbatim = git_repo.cmd.run(["rev-parse", "HEAD"]) - assert "\n" not in sha - assert sha == sha.strip() + assert verbatim.endswith("\n") + # trim=True still yields the convenient bare value. + assert git_repo.cmd.run(["rev-parse", "HEAD"], trim=True) == verbatim.strip() def test_run_failure_preserves_stderr_lines(git_repo: GitSync) -> None: diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py index 7eb965029..235eae3f3 100644 --- a/tests/sync/test_git.py +++ b/tests/sync/test_git.py @@ -115,7 +115,7 @@ def test_repo_git_obtain_full( git_repo: GitSync = constructor(**lazy_constructor_options(**locals())) git_repo.obtain() - test_repo_revision = run(["git", "rev-parse", "HEAD"], cwd=git_remote_repo) + test_repo_revision = run(["git", "rev-parse", "HEAD"], cwd=git_remote_repo).strip() assert git_repo.get_revision() == test_repo_revision assert (tmp_path / "myrepo").exists() @@ -155,7 +155,7 @@ def test_git_shallow_and_tls_verify_kwargs_are_honored( ["git", "rev-parse", "--is-shallow-repository"], cwd=tmp_path / "myrepo", ) - assert is_shallow == "true" + assert is_shallow.strip() == "true" class DepthFixture(t.NamedTuple): @@ -228,7 +228,7 @@ def test_obtain_honors_clone_depth( commit_count = run(["git", "rev-list", "--count", "HEAD"], cwd=checkout) is_shallow = run(["git", "rev-parse", "--is-shallow-repository"], cwd=checkout) assert int(commit_count) == expected_count - assert is_shallow == ("true" if expected_shallow else "false") + assert is_shallow.strip() == ("true" if expected_shallow else "false") @pytest.mark.parametrize( @@ -800,7 +800,7 @@ def test_git_sync_remotes(git_repo: GitSync) -> None: remotes = git_repo.remotes() assert "origin" in remotes - assert git_repo.cmd.remotes.show() == "origin" + assert git_repo.cmd.remotes.show().strip() == "origin" git_origin = git_repo.cmd.remotes.get(remote_name="origin") assert git_origin is not None assert "origin" in git_origin.show() @@ -849,7 +849,7 @@ def test_set_remote(git_repo: GitSync, repo_name: str, new_repo_url: str) -> Non def test_get_git_version(git_repo: GitSync) -> None: """Test get_git_version().""" - expected_version = git_repo.run(["--version"]).replace("git version ", "") + expected_version = git_repo.run(["--version"]).replace("git version ", "").strip() assert git_repo.get_git_version() assert expected_version == git_repo.get_git_version() From 843440da929145545df16791bfafe3ac88ed435c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 19:57:31 -0500 Subject: [PATCH 7/9] docs(adr): Record verbatim-default decision why: The chosen design flipped to verbatim-by-default during review. what: - Update ADR 0001 Phase 1, alternatives, and consequences to reflect verbatim default with `trim=True` opt-in. --- ...0001-faithful-subprocess-output-capture.md | 81 ++++++++++--------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/docs/project/adr/0001-faithful-subprocess-output-capture.md b/docs/project/adr/0001-faithful-subprocess-output-capture.md index 45d0662f8..fd50aca83 100644 --- a/docs/project/adr/0001-faithful-subprocess-output-capture.md +++ b/docs/project/adr/0001-faithful-subprocess-output-capture.md @@ -58,17 +58,17 @@ capture path. This is implemented in two phases so the stable - Capture stdout and stderr as raw bytes and decode once, without per-line stripping or blank-line dropping. Interior whitespace, blank lines, and stream structure are preserved exactly. -- `.run()` keeps returning a convenience string. By default it applies a - single **whole-output** `rstrip()` (trailing whitespace only), - matching the established "bare value" behavior callers already rely on - for reads like `rev-parse HEAD`. -- Add an opt-in for verbatim output (e.g. `trim=False`) so callers that - need byte-accurate output — diffs destined for `git apply`, blob - contents — get exactly what the VCS produced, trailing newline - included. -- Decode stderr for error messages with `errors="backslashreplace"` and - preserve its line structure, so `libvcs.exc.CommandError.output` is - readable and never hides an undecodable byte. +- `.run()` returns the captured output **verbatim by default**, including + the trailing newline, so whitespace-significant output (diffs destined + for `git apply`, blob contents) round-trips byte-for-byte. +- Add a `trim=True` opt-in that applies a single **whole-output** + `rstrip()` for the convenient "bare value" reads where a trailing + newline is just noise (e.g. `rev-parse HEAD`). Trimming is a deliberate + caller choice, never the capture default. +- Preserve stderr's line structure for error messages, and decode it + tolerantly: the UTF-8 fallback uses `errors="backslashreplace"`, so an + undecodable byte surfaces as an escape sequence in + `libvcs.exc.CommandError.output` instead of raising `UnicodeDecodeError`. ### Phase 2 — pristine structured backend @@ -93,43 +93,46 @@ is byte-identical. | Approach | diff applies | blob identical | `rev-parse` bare | errors intact | tests failing | |----------|:------------:|:--------------:|:----------------:|:-------------:|:-------------:| -| Per-line `strip` + drop-blanks (current) | no | no | yes | no | baseline | -| Verbatim string everywhere | yes | yes | no (`+\n`) | yes | 77 | -| Whole-output `rstrip` | no | no | yes | yes | 1 | -| Per-call `trim` flag (chosen, Phase 1) | yes (opt-in) | yes (opt-in) | yes (default) | yes | 1 | -| Structured `CompletedProcess` (chosen, Phase 2) | yes | yes | edge decides | yes | 0 (via facade) | - -Two results are decisive. Returning fully verbatim output as the default -fixes fidelity but breaks 77 tests, because the project's own doctests -and downstream consumers expect `.run()` to return a value with no -trailing newline. A global trailing trim keeps that contract but cannot -produce an applyable patch, since the patch's required final newline is -exactly what gets trimmed. Only a per-call choice satisfies both, and a -structured result removes the choice from the runner entirely by handing -the caller pristine bytes plus separate streams. - -The single failing test under the chosen approaches is a `Svn.blame` -doctest whose expected value had encoded the bug (column-padding spaces -already stripped). It is corrected to expect the faithful output. +| Per-line `strip` + drop-blanks (original) | no | no | yes | no | baseline | +| Whole-output `rstrip` default | no | no | yes | yes | 1 | +| Verbatim default + `trim=True` opt-in (chosen, Phase 1) | yes | yes | opt-in | yes | doctests only | +| Structured `CompletedProcess` (Phase 2) | yes | yes | edge decides | yes | 0 (via facade) | + +The decisive measurement: flipping the default to verbatim broke only +doctests in `cmd/git.py`, `cmd/hg.py`, and `cmd/svn.py` — example output +that gained a trailing newline. No functional test, sync-layer call, or +downstream consumer broke, because those already strip where they need a +bare value (`vcspull`, like the sync layer, trims defensively). A global +trailing trim, by contrast, cannot produce an applyable patch: the +patch's required final newline is exactly what it strips. So verbatim +becomes the default — fixing the original `git apply` failure for the +plain `.run(["diff"])` call — and trimming is an explicit `trim=True` +opt-in for bare-value reads. + +Every affected doctest was updated to show the real verbatim output. The +`Svn.blame` doctest is a notable case: its original expected value had +encoded the old bug (column-padding spaces already stripped), so it now +reflects the true, faithful output. ## Consequences ### Positive -- Captured diffs and patches re-apply; blob reads are byte-identical; - error messages keep their line structure. -- The default `.run()` contract (no trailing newline) is preserved, so - existing callers and the downstream `vcspull`, which strips defensively - before comparing, are unaffected. -- Two latent defects are removed: stderr lines are no longer concatenated - without a separator, and routing through `subprocess.run` avoids the +- Captured diffs and patches re-apply by default; blob reads are + byte-identical; error messages keep their line structure. +- The default now returns output with its trailing newline. Callers that + want a bare value pass `trim=True`; existing consumers are unaffected + because the sync layer and `vcspull` already strip defensively before + comparing. +- The stderr concatenation defect is removed: error lines keep their + separators. (Phase 2's structured backend additionally avoids the pipe-buffer deadlock the legacy poll loop can hit when a child floods - stdout. + stdout.) ### Tradeoffs -- Callers that need pristine output must opt in (Phase 1) or use the - structured accessor (Phase 2); the convenience default still trims. +- Callers that relied on the implicit trailing-newline trim must now pass + `trim=True` (or strip themselves) for bare-value reads. - Phase 2 introduces a second return shape (`CompletedProcess`) alongside the string facade, and migrates the command classes onto a new backend. From fd0d3d944806f868ac5d158a8b257322eba4a29a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 08:27:11 -0500 Subject: [PATCH 8/9] docs(CHANGES): Verbatim command output by default why: Document the verbatim-output breaking change and the related output-fidelity fixes for downstream readers. what: - Add a Breaking changes entry for the verbatim-default `.run()` (with the trim=True migration) and Fixes for applyable diffs, byte-identical blobs, and intact multi-line error output. --- CHANGES | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGES b/CHANGES index 51546773f..ccfe568ed 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,31 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### Breaking changes + +#### Command output is returned verbatim by default (#538) + +`.run()` on {class}`~libvcs.cmd.git.Git`, {class}`~libvcs.cmd.hg.Hg`, and {class}`~libvcs.cmd.svn.Svn` no longer strips each line, drops blank lines, or removes the trailing newline — it returns the output unchanged. + +Before, a bare value came back trimmed: + +```python +sha = git.run(["rev-parse", "HEAD"]) +``` + +After, output is verbatim; pass `trim=True` for the previous behavior: + +```python +sha = git.run(["rev-parse", "HEAD"], trim=True) +``` + +High-level helpers are unaffected: {meth}`~libvcs.sync.git.GitSync.get_revision` still returns a bare revision. + +### Fixes + +- A captured `git diff` re-applies with `git apply` and `git cat-file blob` is byte-identical — leading indentation, blank lines, and the trailing newline are preserved instead of stripped (#538). +- Multi-line stderr in {exc}`~libvcs.exc.CommandError` keeps its line breaks instead of being concatenated into one run-on line (#538). + ## libvcs 0.43.0 (2026-06-20) libvcs 0.43.0 restores compatibility with pytest 9.1. Because the pytest plugin is auto-loaded by pytest, the previous release aborted the test session for any project that had libvcs installed and upgraded to pytest 9.1+; that no longer happens. Downstream tools such as vcspull are the primary beneficiaries. From 0069b207dcb8948092df03e6311c973dca08d6a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:55:54 -0500 Subject: [PATCH 9/9] cmd/git(fix[rev_list]): Honor date-range filters why: rev_list's since/after/until/before/max_age/min_age flags were never applied -- the guard tested the imported `datetime` module instead of the loop value, so the condition was always False. what: - Test the loop value (datetime_kwarg), matching the adjacent flag loop, so the date-range flags reach git rev-list. --- src/libvcs/cmd/git.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index 392bdbae5..11535f88c 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -3,7 +3,6 @@ from __future__ import annotations import dataclasses -import datetime import os import pathlib import re @@ -2207,7 +2206,7 @@ def rev_list( (max_age, "--max-age"), (min_age, "--min-age"), ]: - if datetime_kwarg is not None and isinstance(datetime, str): + if datetime_kwarg is not None and isinstance(datetime_kwarg, str): local_flags.extend([datetime_shell_flag, datetime_kwarg]) for int_flag, int_shell_flag in [