Skip to main content

strop_git/
container.rs

1//! The read-oriented container Git backend (0037 DC1b): the same
2//! bounded `git` queries as the remote backend, executed inside a
3//! running container through the local engine's `docker exec`
4//! ([`GitExec::Container`]) and parsed by the *same* wire parsers —
5//! discovery and context are the exec-generic cores shared with
6//! [`crate::remote`], so no parsing or exit-code mapping is duplicated
7//! here. Like the remote path, no mutation verbs exist: container
8//! repositories are read-only.
9//!
10//! The returned workdir is a path *inside* the container — never a
11//! local path, and no libgit2 handle may be opened against it. The
12//! container boundary carries argv as UTF-8 text and caps retained
13//! output; both surface as typed refusals from the exec layer, and a
14//! truncated stream can never pose as a complete record set.
15
16use std::path::{Path, PathBuf};
17
18use strop_core::worker::CancelToken;
19use strop_workspace::ContainerId;
20
21use crate::exec::GitExec;
22use crate::remote::{context_with, discover_with, RemoteGitError};
23use crate::target::RepoTarget;
24use crate::GitContext;
25
26/// Discover the repository containing an in-container directory.
27/// `Ok(None)` is the honest "no repository here" — git's own
28/// not-a-repository fatal — while engine failures, missing `git` and
29/// corrupt repositories are typed errors. The returned workdir is a
30/// path inside the container, never a local path.
31pub fn discover(
32    id: &ContainerId,
33    from: &Path,
34    cancel: &CancelToken,
35) -> Result<Option<PathBuf>, RemoteGitError> {
36    let exec = GitExec::Container {
37        container: id.clone(),
38        workdir: from,
39    };
40    discover_with(&exec, cancel)
41}
42
43/// The pure cached context of an in-container repository: HEAD sha,
44/// branch and remotes captured once on a worker, the same [`GitContext`]
45/// the local and remote paths produce — with [`RepoTarget::Container`]
46/// as the repo identity. Unborn HEAD and detached HEAD are honest
47/// `None`/name states, distinguished by exit code — never by stderr
48/// text.
49pub fn context(
50    id: &ContainerId,
51    workdir: &Path,
52    cancel: &CancelToken,
53) -> Result<GitContext, RemoteGitError> {
54    let exec = GitExec::Container {
55        container: id.clone(),
56        workdir,
57    };
58    let repo = RepoTarget::Container {
59        container: id.clone(),
60        workdir: workdir.to_path_buf(),
61    };
62    context_with(&exec, repo, cancel)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::exec::GitRun;
69    use crate::remote::{context_from_runs, discover_from_run};
70
71    // Bytes below are captured from real `git` output, mirroring the
72    // remote backend's fixtures; the regressions pin the exact shapes
73    // the shared parsers admit for the container path.
74
75    fn run(code: i32, stdout: &[u8], stderr: &[u8]) -> GitRun {
76        GitRun {
77            success: code == 0,
78            code: Some(code),
79            stdout: stdout.to_vec(),
80            stderr: stderr.to_vec(),
81            stdout_dropped: 0,
82            stderr_dropped: 0,
83        }
84    }
85
86    fn id() -> ContainerId {
87        ContainerId::canonical("a".repeat(64)).expect("64 hex is a canonical id")
88    }
89
90    /// The container backend shares the discovery mapping: 128 with
91    /// git's not-a-repository fatal is the honest `None`, any other
92    /// failure stays typed, and a good run parses the in-container root.
93    #[test]
94    fn no_repo_is_none_and_other_failures_are_typed() {
95        let absent = run(
96            128,
97            b"",
98            b"fatal: not a git repository (or any of the parent directories): .git\n",
99        );
100        assert_eq!(discover_from_run(&absent), Ok(None));
101
102        let present = run(0, b"/work/app\n", b"");
103        assert_eq!(
104            discover_from_run(&present).unwrap(),
105            Some(PathBuf::from("/work/app"))
106        );
107
108        match discover_from_run(&run(128, b"", b"fatal: unsafe repository\n")) {
109            Err(RemoteGitError::Exit { op, code, stderr }) => {
110                assert_eq!(op, "rev-parse --show-toplevel");
111                assert_eq!(code, 128);
112                assert_eq!(stderr, "fatal: unsafe repository");
113            }
114            other => panic!("expected Exit, got {other:?}"),
115        }
116    }
117
118    /// The container context is the same GitContext the remote path
119    /// produces, only the target differs: detached HEAD is an honest
120    /// `None` branch, and remotes come from the shared config parser.
121    #[test]
122    fn context_reports_container_target_and_head_state() {
123        let repo = RepoTarget::Container {
124            container: id(),
125            workdir: PathBuf::from("/work/app"),
126        };
127        let head = run(0, b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45\n", b"");
128        let branch = run(128, b"", b"fatal: ref HEAD is not a symbolic ref\n");
129        let config = run(0, b"remote.origin.url\ngit@gh:acme/demo.git\0", b"");
130        let context = context_from_runs(repo.clone(), &head, &branch, &config).unwrap();
131        assert_eq!(context.repo, repo);
132        assert_eq!(
133            context.head_sha.as_deref(),
134            Some("c59d8ceb7aeb96a1cdccff5646ec485acce32d45")
135        );
136        assert_eq!(context.head_branch, None, "detached HEAD");
137        assert_eq!(
138            context.remotes,
139            vec![("origin".to_string(), "git@gh:acme/demo.git".to_string())]
140        );
141    }
142
143    /// Unborn HEAD: rev-parse exits 1 (no commits) while symbolic-ref
144    /// still names the branch; a repo with no remotes exits 1 too —
145    /// all three are data, never errors.
146    #[test]
147    fn unborn_head_and_no_remotes_are_honest_data() {
148        let repo = RepoTarget::Container {
149            container: id(),
150            workdir: PathBuf::from("/work/app"),
151        };
152        let head = run(1, b"", b"");
153        let branch = run(0, b"main\n", b"");
154        let config = run(1, b"", b"");
155        let context = context_from_runs(repo, &head, &branch, &config).unwrap();
156        assert_eq!(context.head_sha, None, "unborn HEAD");
157        assert_eq!(context.head_branch.as_deref(), Some("main"));
158        assert!(context.remotes.is_empty());
159    }
160}