Skip to main content

strop_git/
remote.rs

1//! The read-oriented remote Git backend (0036 RW8): bounded `git`
2//! commands against a worktree that exists only on an SSH endpoint,
3//! executed through the shared remote-execution boundary and parsed by
4//! the same structured parsers as the local backend. No libgit2 here —
5//! no remote filesystem is ever assumed local — and no mutation verbs
6//! exist on this path at all (RW4 keeps remote repositories read-only).
7//!
8//! Every machine-format boundary (`ls-tree`/`ls-files` records,
9//! `rev-parse` output, `config -z` remotes, `rev-list --parents`) is a
10//! pure parser fed by bytes captured from real `git`, so native —
11//! possibly non-UTF-8 — filenames stay worktree identities and exit
12//! codes carry the meaning instead of stderr matching. Unborn HEAD and
13//! absent paths are honest `None`s; transport, tooling and truncation
14//! failures are typed errors.
15
16use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18
19use strop_core::worker::CancelToken;
20use strop_remote::RemoteEndpoint;
21
22use crate::diff::FileDiff;
23use crate::exec::{GitExec, GitExecError, GitRun};
24use crate::repo::{gutter_from_contents, hunks_from_buffers};
25use crate::ssh::parse_effective_hostname;
26use crate::target::RepoTarget;
27use crate::{GitContext, Hunk};
28
29/// Why a remote Git query failed — typed at this boundary, never a
30/// bare string and never an empty list standing in for "failed".
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum RemoteGitError {
33    /// The remote execution boundary refused or failed; the string is
34    /// its own typed diagnosis (transport, supervisor, missing
35    /// python3, cancellation, timeout…).
36    Exec(String),
37    /// `git` itself exited nonzero: the operation name, exit code and
38    /// git's stderr.
39    Exit {
40        op: &'static str,
41        code: i32,
42        stderr: String,
43    },
44    /// A record git produced did not have the shape this parser
45    /// admits — corrupt or surprising output is refused, never guessed.
46    Parse(&'static str),
47    /// Blob content that must be UTF-8 for the surface was not.
48    Utf8(&'static str),
49    /// The remote `git` output bound truncated a stream this query
50    /// needs complete.
51    Truncated { op: &'static str, dropped: u64 },
52}
53
54impl std::fmt::Display for RemoteGitError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::Exec(message) => write!(f, "{message}"),
58            Self::Exit { op, code, stderr } => {
59                write!(f, "{op}: git exited {code}: {}", stderr.trim())
60            }
61            Self::Parse(what) => write!(f, "{what}: unparseable git output"),
62            Self::Utf8(what) => write!(f, "{what} is not UTF-8"),
63            Self::Truncated { op, dropped } => {
64                write!(f, "{op}: remote output truncated ({dropped} bytes dropped)")
65            }
66        }
67    }
68}
69
70impl std::error::Error for RemoteGitError {}
71
72impl From<GitExecError> for RemoteGitError {
73    fn from(error: GitExecError) -> Self {
74        Self::Exec(error.to_string())
75    }
76}
77
78/// The typed result of one bounded run that must exit zero with
79/// complete stdout: bytes, or the honest failure.
80fn records(
81    exec: &GitExec,
82    op: &'static str,
83    argv: &[OsString],
84    cancel: &CancelToken,
85) -> Result<Vec<u8>, RemoteGitError> {
86    let run = exec.run(argv, cancel)?;
87    exit_or_bytes(op, &run)
88}
89
90fn exit_or_bytes(op: &'static str, run: &GitRun) -> Result<Vec<u8>, RemoteGitError> {
91    if run.stdout_dropped > 0 {
92        return Err(RemoteGitError::Truncated {
93            op,
94            dropped: run.stdout_dropped,
95        });
96    }
97    match run.code {
98        Some(0) => Ok(run.stdout.clone()),
99        Some(code) => Err(RemoteGitError::Exit {
100            op,
101            code,
102            stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
103        }),
104        None => Err(RemoteGitError::Exit {
105            op,
106            code: -1,
107            stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
108        }),
109    }
110}
111
112/// Discover the repository containing a remote directory. `Ok(None)` is
113/// the honest "no repository here" — git's own not-a-repository fatal
114/// — while transport failures, missing `git`/python3 and corrupt
115/// repositories are typed errors. The returned workdir is a path on
116/// the endpoint, native bytes, never a local path.
117pub fn discover(
118    endpoint: &RemoteEndpoint,
119    from: &Path,
120    cancel: &CancelToken,
121) -> Result<Option<PathBuf>, RemoteGitError> {
122    let exec = GitExec::Remote {
123        endpoint: endpoint.clone(),
124        workdir: from,
125    };
126    let run = exec.run(&["rev-parse".into(), "--show-toplevel".into()], cancel)?;
127    if run.success {
128        let stdout = exit_or_bytes("rev-parse --show-toplevel", &run)?;
129        return parse_toplevel(&stdout).map(Some);
130    }
131    // 128 with git's stable not-a-repository fatal: no repository. Any
132    // other nonzero (unsafe repository, broken .git, missing git…)
133    // stays a typed error.
134    let stderr = String::from_utf8_lossy(&run.stderr);
135    if run.code == Some(128) && stderr.contains("not a git repository") {
136        return Ok(None);
137    }
138    Err(RemoteGitError::Exit {
139        op: "rev-parse --show-toplevel",
140        code: run.code.unwrap_or(-1),
141        stderr: stderr.trim_end().to_string(),
142    })
143}
144
145/// The pure cached context of a remote repository (R6): HEAD sha,
146/// branch and remotes captured once on a worker. Unborn HEAD and
147/// detached HEAD are honest `None`/name states, distinguished by exit
148/// code — never by stderr text.
149pub fn context(
150    endpoint: &RemoteEndpoint,
151    workdir: &Path,
152    cancel: &CancelToken,
153) -> Result<GitContext, RemoteGitError> {
154    let exec = GitExec::Remote {
155        endpoint: endpoint.clone(),
156        workdir,
157    };
158    // exit 0 = sha, exit 1 = unborn (no commits), anything else fails.
159    let head_sha = match exec.run(
160        &[
161            "rev-parse".into(),
162            "--verify".into(),
163            "--quiet".into(),
164            "HEAD".into(),
165        ],
166        cancel,
167    )? {
168        run if run.success => Some(parse_sha(&run.stdout, "rev-parse HEAD")?),
169        run if run.code == Some(1) => None,
170        run => {
171            return Err(RemoteGitError::Exit {
172                op: "rev-parse HEAD",
173                code: run.code.unwrap_or(-1),
174                stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
175            })
176        }
177    };
178    // exit 0 = branch name (unborn included: the symref exists), 128 =
179    // detached HEAD (no symbolic ref), anything else fails.
180    let head_branch = match exec.run(
181        &["symbolic-ref".into(), "--short".into(), "HEAD".into()],
182        cancel,
183    )? {
184        run if run.success => {
185            let name = String::from_utf8_lossy(&run.stdout).trim().to_string();
186            (!name.is_empty()).then_some(name)
187        }
188        run if run.code == Some(128) => None,
189        run => {
190            return Err(RemoteGitError::Exit {
191                op: "symbolic-ref HEAD",
192                code: run.code.unwrap_or(-1),
193                stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
194            })
195        }
196    };
197    // exit 0 or 1 (no remotes configured): both are data.
198    let config_run = exec.run(
199        &[
200            "config".into(),
201            "-z".into(),
202            "--get-regexp".into(),
203            "^remote\\.[^.]+\\.url$".into(),
204        ],
205        cancel,
206    )?;
207    if config_run.code != Some(0) && config_run.code != Some(1) {
208        return Err(RemoteGitError::Exit {
209            op: "config --get-regexp remote.*.url",
210            code: config_run.code.unwrap_or(-1),
211            stderr: String::from_utf8_lossy(&config_run.stderr)
212                .trim_end()
213                .to_string(),
214        });
215    }
216    let stdout = exit_or_bytes("config --get-regexp remote.*.url", &config_run)?;
217    let remotes = parse_remote_config(&stdout)?;
218    Ok(GitContext {
219        repo: RepoTarget::Remote {
220            endpoint: endpoint.clone(),
221            workdir: workdir.to_path_buf(),
222        },
223        head_sha,
224        head_branch,
225        remotes,
226    })
227}
228
229/// HEAD's and the index's blob bytes for one repo-relative path — the
230/// gutter diff's inputs. `head_sha` is the context's resolved HEAD (an
231/// unborn repository passes `None` and HEAD is not asked). Absent from
232/// HEAD's tree / absent from the index are honest `None`s.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct FileContents {
235    pub head: Option<Vec<u8>>,
236    pub index: Option<Vec<u8>>,
237}
238
239/// Fetch a file's HEAD and index contents and assemble the gutter's
240/// typed hunk sets against the live buffer text — the same semantics
241/// [`crate::Repo::unstaged_hunks`]/[`staged_hunks`]/[`is_untracked`]
242/// give local buffers, from bounded remote reads. Untracked files
243/// report one all-add hunk, matching local behavior.
244pub fn gutter(
245    endpoint: &RemoteEndpoint,
246    workdir: &Path,
247    head_sha: Option<&str>,
248    rel: &Path,
249    text: &str,
250    cancel: &CancelToken,
251) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), RemoteGitError> {
252    let contents = file_contents(endpoint, workdir, head_sha, rel, cancel)?;
253    let head = contents
254        .head
255        .as_deref()
256        .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("HEAD blob")))
257        .transpose()?;
258    let index = contents
259        .index
260        .as_deref()
261        .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("index blob")))
262        .transpose()?;
263    gutter_from_contents(head, index, text, rel)
264        .map_err(|error| RemoteGitError::Exec(error.to_string()))
265}
266
267/// One file's HEAD/index blob bytes via machine-readable presence
268/// checks (`ls-tree`/`ls-files`, `-z`, literal pathspec) followed by
269/// `cat-file` on the recorded oid — never a `:path` spelling whose
270/// absence would need stderr matching to interpret.
271pub fn file_contents(
272    endpoint: &RemoteEndpoint,
273    workdir: &Path,
274    head_sha: Option<&str>,
275    rel: &Path,
276    cancel: &CancelToken,
277) -> Result<FileContents, RemoteGitError> {
278    let exec = GitExec::Remote {
279        endpoint: endpoint.clone(),
280        workdir,
281    };
282    let head = match head_sha {
283        Some(sha) => {
284            let stdout = records(
285                &exec,
286                "ls-tree HEAD",
287                &[
288                    "ls-tree".into(),
289                    "-z".into(),
290                    sha.into(),
291                    "--".into(),
292                    rel.as_os_str().into(),
293                ],
294                cancel,
295            )?;
296            match parse_tree_entry(&stdout)? {
297                Some((oid, path)) if path == rel => {
298                    Some(blob_bytes(&exec, &oid, "HEAD blob", cancel)?)
299                }
300                // an empty record set is the honest absence; a record
301                // for a different path cannot come from a literal
302                // pathspec and is refused
303                Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree HEAD")),
304                None => None,
305            }
306        }
307        None => None,
308    };
309    let stdout = records(
310        &exec,
311        "ls-files --stage",
312        &[
313            "ls-files".into(),
314            "-z".into(),
315            "--stage".into(),
316            "--".into(),
317            rel.as_os_str().into(),
318        ],
319        cancel,
320    )?;
321    let index = match parse_index_entry(&stdout)? {
322        Some((oid, path)) if path == rel => Some(blob_bytes(&exec, &oid, "index blob", cancel)?),
323        Some((_, _)) => return Err(RemoteGitError::Parse("ls-files --stage")),
324        None => None,
325    };
326    Ok(FileContents { head, index })
327}
328
329fn blob_bytes(
330    exec: &GitExec,
331    oid: &str,
332    what: &'static str,
333    cancel: &CancelToken,
334) -> Result<Vec<u8>, RemoteGitError> {
335    records(
336        exec,
337        what,
338        &["cat-file".into(), "-p".into(), oid.into()],
339        cancel,
340    )
341}
342
343/// One file's structured delta at `sha` against its first parent (root
344/// commits diff against empty): the dive view's data, built from the
345/// two blobs and the same hunk builder the local libgit2 path uses.
346pub fn commit_file_diff(
347    endpoint: &RemoteEndpoint,
348    workdir: &Path,
349    sha: &str,
350    rel: &Path,
351    cancel: &CancelToken,
352) -> Result<FileDiff, RemoteGitError> {
353    let exec = GitExec::Remote {
354        endpoint: endpoint.clone(),
355        workdir,
356    };
357    let stdout = records(
358        &exec,
359        "rev-list --parents",
360        &[
361            "rev-list".into(),
362            "--parents".into(),
363            "-n".into(),
364            "1".into(),
365            sha.into(),
366        ],
367        cancel,
368    )?;
369    let (commit, parent) = parse_commit_parents(&stdout)?;
370    if commit != sha {
371        return Err(RemoteGitError::Parse("rev-list --parents"));
372    }
373    let parent_blob = match parent.as_deref() {
374        Some(parent) => {
375            let stdout = records(
376                &exec,
377                "ls-tree parent",
378                &[
379                    "ls-tree".into(),
380                    "-z".into(),
381                    parent.into(),
382                    "--".into(),
383                    rel.as_os_str().into(),
384                ],
385                cancel,
386            )?;
387            match parse_tree_entry(&stdout)? {
388                Some((oid, path)) if path == rel => {
389                    Some(blob_bytes(&exec, &oid, "parent blob", cancel)?)
390                }
391                Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree parent")),
392                None => None,
393            }
394        }
395        None => None,
396    };
397    let stdout = records(
398        &exec,
399        "ls-tree commit",
400        &[
401            "ls-tree".into(),
402            "-z".into(),
403            commit.clone().into(),
404            "--".into(),
405            rel.as_os_str().into(),
406        ],
407        cancel,
408    )?;
409    let commit_blob = match parse_tree_entry(&stdout)? {
410        Some((oid, path)) if path == rel => blob_bytes(&exec, &oid, "commit blob", cancel)?,
411        Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree commit")),
412        None => return Err(RemoteGitError::Exec("no diff for path".into())),
413    };
414    let hunks = hunks_from_buffers(parent_blob.as_deref(), &commit_blob, rel)
415        .map_err(|error| RemoteGitError::Exec(error.to_string()))?;
416    Ok(FileDiff::from_hunks(rel.to_path_buf(), hunks))
417}
418
419/// Resolve an SSH alias against OpenSSH's effective configuration *on
420/// the endpoint* (`ssh -G` run remotely): the alias belongs to the
421/// machine whose remote carries it, and evaluating it with the local
422/// user's config would answer for the wrong host. Owned worker work,
423/// cancellable like every bounded remote run.
424pub fn effective_host(
425    endpoint: &RemoteEndpoint,
426    workdir: &Path,
427    remote: &crate::permalink::AliasRemote,
428    cancel: &CancelToken,
429) -> Result<String, crate::ssh::EffectiveHostError> {
430    use crate::ssh::EffectiveHostError;
431    let host = remote.host();
432    if !crate::permalink::is_safe_host(host) {
433        return Err(EffectiveHostError::InvalidHost);
434    }
435    let mut args = vec!["-G".into()];
436    if let Some(user) = &remote.user {
437        args.extend(["-l".into(), user.into()]);
438    }
439    if let Some(port) = remote.port {
440        args.extend(["-p".into(), port.to_string().into()]);
441    }
442    args.push(host.into());
443    let command = strop_remote::RemoteCommand::new("ssh", args, workdir)
444        .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
445    let run = strop_remote::run(endpoint, &command, cancel)
446        .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
447    if !run.status.success() {
448        return Err(EffectiveHostError::Failed(
449            String::from_utf8_lossy(&run.stderr).trim().to_string(),
450        ));
451    }
452    if run.stdout_dropped > 0 {
453        return Err(EffectiveHostError::Failed(
454            "output truncated before a hostname line".into(),
455        ));
456    }
457    let stdout = String::from_utf8_lossy(&run.stdout);
458    let hostname = parse_effective_hostname(&stdout).ok_or(EffectiveHostError::NoHostname)?;
459    if hostname == host && !hostname.contains('.') {
460        return Err(EffectiveHostError::Unresolved);
461    }
462    Ok(hostname)
463}
464
465// ---- pure wire parsers ---------------------------------------------------
466//
467// Each parser is fed by bytes captured from real `git` (see tests):
468// native filename bytes survive, exit-code meanings are documented at
469// their call sites, and nothing is guessed from stderr text.
470
471/// `git rev-parse --show-toplevel`: one absolute native path with a
472/// trailing newline. Relative or empty output is refused — a worktree
473/// root on the endpoint is absolute by definition.
474fn parse_toplevel(bytes: &[u8]) -> Result<PathBuf, RemoteGitError> {
475    let trimmed = bytes.strip_suffix(b"\n").unwrap_or(bytes);
476    if trimmed.is_empty() || trimmed.first() != Some(&b'/') {
477        return Err(RemoteGitError::Parse("rev-parse --show-toplevel"));
478    }
479    Ok(bytes_to_path(trimmed))
480}
481
482/// A full object name: 40–64 lowercase-or-uppercase hex characters.
483fn parse_sha(bytes: &[u8], what: &'static str) -> Result<String, RemoteGitError> {
484    let text = String::from_utf8_lossy(bytes);
485    let sha = text.trim();
486    let valid = (40..=64).contains(&sha.len())
487        && !sha.is_empty()
488        && sha.bytes().all(|b| b.is_ascii_hexdigit());
489    if !valid {
490        return Err(RemoteGitError::Parse(what));
491    }
492    Ok(sha.to_string())
493}
494
495/// `git config -z --get-regexp '^remote\.[^.]+\.url$'` records:
496/// `remote.<name>.url\n<url>\0`. Names and URLs are config strings;
497/// non-UTF-8 values are refused rather than lossily renamed.
498fn parse_remote_config(bytes: &[u8]) -> Result<Vec<(String, String)>, RemoteGitError> {
499    let mut remotes = Vec::new();
500    for record in bytes.split(|&b| b == 0) {
501        if record.is_empty() {
502            continue;
503        }
504        let Some((key, url)) = split_record(record, b'\n') else {
505            return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
506        };
507        let key = std::str::from_utf8(key).map_err(|_| RemoteGitError::Utf8("remote name"))?;
508        let url = std::str::from_utf8(url).map_err(|_| RemoteGitError::Utf8("remote url"))?;
509        let Some(name) = key
510            .strip_prefix("remote.")
511            .and_then(|rest| rest.strip_suffix(".url"))
512        else {
513            return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
514        };
515        if name.is_empty() {
516            return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
517        }
518        remotes.push((name.to_string(), url.to_owned()));
519    }
520    Ok(remotes)
521}
522
523/// One `ls-tree -z` record: `mode SP type SP oid TAB path`. `None` for
524/// an empty record set (the path is absent); the oid is validated hex
525/// before anything embeds it in a later argv.
526fn parse_tree_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
527    let Some(record) = first_record(bytes) else {
528        return Ok(None);
529    };
530    let Some((meta, path)) = split_record(record, b'\t') else {
531        return Err(RemoteGitError::Parse("ls-tree"));
532    };
533    let mut fields = meta.split(|&b| b == b' ');
534    let oid = fields.nth(2).ok_or(RemoteGitError::Parse("ls-tree"))?;
535    let oid = parse_sha(oid, "ls-tree oid")?;
536    Ok(Some((oid, bytes_to_path(path))))
537}
538
539/// One `ls-files -z --stage` record: `mode SP oid SP stage TAB path`.
540fn parse_index_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
541    let Some(record) = first_record(bytes) else {
542        return Ok(None);
543    };
544    let Some((meta, path)) = split_record(record, b'\t') else {
545        return Err(RemoteGitError::Parse("ls-files --stage"));
546    };
547    let mut fields = meta.split(|&b| b == b' ');
548    let oid = fields
549        .nth(1)
550        .ok_or(RemoteGitError::Parse("ls-files --stage"))?;
551    let oid = parse_sha(oid, "ls-files oid")?;
552    Ok(Some((oid, bytes_to_path(path))))
553}
554
555/// `git rev-list --parents -n 1 <sha>`: `child [parent…]`, all full
556/// object names. The first parent is the delta base (merges diff
557/// against parent(0), matching local behavior).
558fn parse_commit_parents(bytes: &[u8]) -> Result<(String, Option<String>), RemoteGitError> {
559    let text = String::from_utf8_lossy(bytes);
560    let mut shas = text
561        .split_whitespace()
562        .map(|token| parse_sha(token.as_bytes(), "rev-list --parents"));
563    let commit = shas
564        .next()
565        .ok_or(RemoteGitError::Parse("rev-list --parents"))??;
566    let parent = shas.next().transpose()?;
567    Ok((commit, parent))
568}
569
570fn first_record(bytes: &[u8]) -> Option<&[u8]> {
571    bytes.split(|&b| b == 0).find(|record| !record.is_empty())
572}
573
574fn split_record(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
575    let index = bytes.iter().position(|&byte| byte == separator)?;
576    Some((&bytes[..index], &bytes[index + 1..]))
577}
578
579#[cfg(unix)]
580fn bytes_to_path(bytes: &[u8]) -> PathBuf {
581    use std::os::unix::ffi::OsStrExt;
582    PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
583}
584
585#[cfg(not(unix))]
586fn bytes_to_path(bytes: &[u8]) -> PathBuf {
587    PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    // Bytes below are captured from real `git` output (see
595    // crates/strop-git/src/remote.rs doc comment); the regressions pin
596    // the exact shapes the remote backend admits.
597
598    #[test]
599    fn toplevel_is_absolute_native_path() {
600        assert_eq!(
601            parse_toplevel(b"/srv/proj with space\n").unwrap(),
602            PathBuf::from("/srv/proj with space")
603        );
604        assert!(
605            parse_toplevel(b"srv/proj\n").is_err(),
606            "relative is refused"
607        );
608        assert!(parse_toplevel(b"").is_err(), "empty is refused");
609    }
610
611    #[test]
612    fn shas_are_validated_object_names() {
613        assert_eq!(
614            parse_sha(b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45\n", "x").unwrap(),
615            "c59d8ceb7aeb96a1cdccff5646ec485acce32d45"
616        );
617        assert!(parse_sha(b"main\n", "x").is_err());
618        assert!(parse_sha(b"head is at 1234\n", "x").is_err());
619        assert!(parse_sha(b"\n", "x").is_err());
620    }
621
622    /// config -z records split key\nvalue on NUL; a remote with no
623    /// remotes is an empty vector, and a non-UTF-8 URL is refused.
624    #[test]
625    fn remote_config_records_parse_native() {
626        let bytes = b"remote.origin.url\nhttps://example.com/acme/demo.git\0remote.up.url\ngit@gh:acme/other.git\0";
627        assert_eq!(
628            parse_remote_config(bytes).unwrap(),
629            vec![
630                (
631                    "origin".to_string(),
632                    "https://example.com/acme/demo.git".to_string()
633                ),
634                ("up".to_string(), "git@gh:acme/other.git".to_string()),
635            ]
636        );
637        assert_eq!(
638            parse_remote_config(b"").unwrap(),
639            Vec::<(String, String)>::new()
640        );
641        assert!(parse_remote_config(b"not-a-pair\0").is_err());
642        assert!(
643            parse_remote_config(b"remote..url\nx\0").is_err(),
644            "empty name"
645        );
646        assert!(
647            parse_remote_config(b"remote.o.url\nhttps://a/\xff\xfe\0").is_err(),
648            "non-UTF-8 url refused"
649        );
650    }
651
652    /// ls-tree/ls-files -z records keep native path bytes — spaces,
653    /// quotes and non-UTF-8 names arrive as the worktree identity —
654    /// and an empty record set is the honest absence.
655    #[test]
656    fn tree_and_index_records_keep_native_paths() {
657        let tree = b"100644 blob 45b983be36b73c0788dc9cbcb76cbb80fc7bb057\tsrc/a b.rs\0";
658        let (oid, path) = parse_tree_entry(tree).unwrap().unwrap();
659        assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
660        assert_eq!(path, PathBuf::from("src/a b.rs"));
661        assert_eq!(parse_tree_entry(b"").unwrap(), None);
662
663        let index = b"100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0\tsrc/a b.rs\0";
664        let (oid, path) = parse_index_entry(index).unwrap().unwrap();
665        assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
666        assert_eq!(path, PathBuf::from("src/a b.rs"));
667        assert_eq!(parse_index_entry(b"").unwrap(), None);
668        assert!(parse_tree_entry(b"garbage\0").is_err());
669        assert!(parse_index_entry(b"100644 noshahere 0\tx\0").is_err());
670    }
671
672    /// rev-list --parents: child first, then parents; a root commit has
673    /// none, and both shapes parse to (commit, Option<parent>).
674    #[test]
675    fn commit_parents_root_and_merged() {
676        let root = b"60209d7ce72dddfafc1caacd511325c314a083bf\n";
677        assert_eq!(
678            parse_commit_parents(root).unwrap(),
679            ("60209d7ce72dddfafc1caacd511325c314a083bf".to_string(), None)
680        );
681        let merged = b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45 aaaa1111111111111111111111111111111111111 bbbb2222222222222222222222222222222222222\n";
682        let (child, parent) = parse_commit_parents(merged).unwrap();
683        assert_eq!(child, "c59d8ceb7aeb96a1cdccff5646ec485acce32d45");
684        assert_eq!(
685            parent.as_deref(),
686            Some("aaaa1111111111111111111111111111111111111")
687        );
688        assert!(parse_commit_parents(b"not-a-sha\n").is_err());
689        assert!(parse_commit_parents(b"").is_err());
690    }
691
692    /// Exit-code meaning is preserved end to end: the error carries the
693    /// operation, git's own exit code and stderr — never a guessed
694    /// empty result.
695    #[test]
696    fn nonzero_exit_is_a_typed_error() {
697        let run = GitRun {
698            success: false,
699            code: Some(128),
700            stdout: Vec::new(),
701            stderr: b"fatal: unsafe repository\n".to_vec(),
702            stdout_dropped: 0,
703            stderr_dropped: 0,
704        };
705        match exit_or_bytes("rev-parse --show-toplevel", &run) {
706            Err(RemoteGitError::Exit { op, code, stderr }) => {
707                assert_eq!(op, "rev-parse --show-toplevel");
708                assert_eq!(code, 128);
709                assert_eq!(stderr, "fatal: unsafe repository");
710            }
711            other => panic!("expected Exit, got {other:?}"),
712        }
713    }
714
715    /// Truncated stdout cannot pose as a complete record stream.
716    #[test]
717    fn truncation_is_typed() {
718        let run = GitRun {
719            success: true,
720            code: Some(0),
721            stdout: Vec::new(),
722            stderr: Vec::new(),
723            stdout_dropped: 4096,
724            stderr_dropped: 0,
725        };
726        assert_eq!(
727            exit_or_bytes("ls-tree", &run),
728            Err(RemoteGitError::Truncated {
729                op: "ls-tree",
730                dropped: 4096
731            })
732        );
733        assert_eq!(
734            RemoteGitError::Truncated {
735                op: "ls-tree",
736                dropped: 9
737            }
738            .to_string(),
739            "ls-tree: remote output truncated (9 bytes dropped)"
740        );
741    }
742}