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