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