Skip to main content

strop_git/
target.rs

1//! The typed repository boundary (0036 RW8, 0037 DC1b): every Git
2//! request names the machine its worktree lives on. A local workdir is
3//! openable with libgit2 and local `git`; a remote workdir is bytes on
4//! another host and only bounded remote `git` commands can read it; a
5//! container workdir is bytes inside a running container and only
6//! bounded `docker exec` runs can read it. Keeping the three in one
7//! enum — instead of a bare path that could mean any of them — makes
8//! "treated a non-local path as local" a type error instead of a bug.
9//!
10//! The same boundary carries provenance through the memory surfaces:
11//! a log row's dive, a commit's file list and a delta's `]f` step all
12//! replay the [`RepoTarget`] they were launched with, so a remote or
13//! container surface can never answer from the local cwd.
14
15use std::path::{Path, PathBuf};
16
17use strop_workspace::{ContainerId, RemoteEndpoint, RemoteFile};
18
19/// Where a Git query runs. Exactly three real backends exist (0036,
20/// 0037); there is deliberately no provider trait behind them.
21#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum RepoTarget {
24    /// A worktree on this machine: libgit2 and local `git` apply.
25    Local {
26        #[serde(with = "strop_core::path_serde")]
27        workdir: PathBuf,
28    },
29    /// A worktree on `endpoint`. The workdir names a path on the
30    /// remote host — it is never a valid local path, and no libgit2
31    /// handle may be opened against it.
32    Remote {
33        endpoint: RemoteEndpoint,
34        #[serde(with = "strop_core::path_serde")]
35        workdir: PathBuf,
36    },
37    /// A worktree inside a running container on the local engine. The
38    /// workdir names a path *inside* the container — it is never a
39    /// valid local path, and no libgit2 handle may be opened against
40    /// it. Identity is the canonical 64-hex inspect id: a stopped or
41    /// restarted container's reads fail typed at the engine boundary.
42    Container {
43        container: ContainerId,
44        #[serde(with = "strop_core::path_serde")]
45        workdir: PathBuf,
46    },
47}
48
49impl RepoTarget {
50    /// The repository root as native bytes — locally openable only for
51    /// [`RepoTarget::Local`]; a remote or container workdir names a path
52    /// on another filesystem namespace. Callers that need to open it
53    /// must match on the variant first.
54    pub fn workdir(&self) -> &Path {
55        match self {
56            Self::Local { workdir } | Self::Remote { workdir, .. } => workdir,
57            Self::Container { workdir, .. } => workdir,
58        }
59    }
60
61    /// The remote-file identity for a repo-relative path (SSH-remote
62    /// repositories only): endpoint plus native path, the identity an
63    /// open request routes by. Local and container repositories have no
64    /// remote file.
65    pub fn remote_file(&self, rel: &Path) -> Option<RemoteFile> {
66        match self {
67            Self::Local { .. } | Self::Container { .. } => None,
68            Self::Remote { endpoint, workdir } => {
69                RemoteFile::from_path(endpoint.clone(), workdir.join(rel)).ok()
70            }
71        }
72    }
73
74    /// `true` only for an SSH-remote repository. A container repository
75    /// is *not* remote in this sense — it has no endpoint and rides the
76    /// local engine — but it is not local either: callers deciding "can
77    /// I open this path" must not treat `!is_remote()` as local.
78    pub fn is_remote(&self) -> bool {
79        matches!(self, Self::Remote { .. })
80    }
81
82    /// `true` for a container repository: not locally openable even
83    /// though it shares this machine's engine.
84    pub fn is_container(&self) -> bool {
85        matches!(self, Self::Container { .. })
86    }
87
88    /// Repo-relative path for a path inside this repository — the
89    /// remote flavor strips the *remote* workdir. `None` is the typed
90    /// refusal for a path that is not inside the repository at all.
91    pub fn rel_of(&self, path: &Path) -> Option<PathBuf> {
92        path.strip_prefix(self.workdir())
93            .ok()
94            .map(|rel| rel.to_path_buf())
95    }
96
97    /// The absolute path of a repo-relative path *on the machine this
98    /// repository lives on*.
99    pub fn abs_of(&self, rel: &Path) -> PathBuf {
100        self.workdir().join(rel)
101    }
102
103    pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
104        match self {
105            Self::Local { .. } | Self::Container { .. } => None,
106            Self::Remote { endpoint, .. } => Some(endpoint),
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn remote_target() -> RepoTarget {
116        RepoTarget::Remote {
117            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
118            workdir: PathBuf::from("/srv/proj"),
119        }
120    }
121
122    /// The boundary is identity: equal targets mean the same repository
123    /// on the same machine; a different port is a different repository.
124    #[test]
125    fn remote_identity_is_endpoint_plus_workdir() {
126        let a = remote_target();
127        let same = RepoTarget::Remote {
128            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
129            workdir: PathBuf::from("/srv/proj"),
130        };
131        let other_port = RepoTarget::Remote {
132            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2223").unwrap(),
133            workdir: PathBuf::from("/srv/proj"),
134        };
135        let other_dir = RepoTarget::Remote {
136            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
137            workdir: PathBuf::from("/other"),
138        };
139        assert_eq!(a, same);
140        assert_ne!(a, other_port);
141        assert_ne!(a, other_dir);
142        assert_ne!(
143            a,
144            RepoTarget::Local {
145                workdir: PathBuf::from("/srv/proj")
146            }
147        );
148    }
149
150    /// A remote path is never relative to the local machine: rel_of
151    /// strips the REMOTE workdir, and the same spelling stays a local
152    /// path under the local variant — the two never interchange.
153    #[test]
154    fn rel_of_strips_the_owning_workdir() {
155        let target = remote_target();
156        assert_eq!(
157            target.rel_of(Path::new("/srv/proj/src/main.rs")),
158            Some(PathBuf::from("src/main.rs"))
159        );
160        assert_eq!(target.rel_of(Path::new("/home/me/src/main.rs")), None);
161    }
162
163    /// remote_file rebuilds the canonical open identity: same endpoint,
164    /// native path under the remote workdir — what a dive's source open
165    /// routes to Main with.
166    #[test]
167    fn remote_file_carries_endpoint_and_native_path() {
168        let target = remote_target();
169        let file = target.remote_file(Path::new("src/a b.rs")).unwrap();
170        assert_eq!(
171            file.endpoint(),
172            &RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap()
173        );
174        assert_eq!(file.path(), Path::new("/srv/proj/src/a b.rs"));
175        assert!(target.remote_file(Path::new("x")).is_some());
176    }
177
178    /// Local repositories have no remote identity and no endpoint —
179    /// refusal, not a local stand-in.
180    #[test]
181    fn local_target_has_no_remote_identity() {
182        let target = RepoTarget::Local {
183            workdir: PathBuf::from("/w"),
184        };
185        assert_eq!(target.endpoint(), None);
186        assert!(!target.is_remote());
187        assert_eq!(target.remote_file(Path::new("a.rs")), None);
188    }
189
190    /// Provenance survives the replay wire: serde round-trips a remote
191    /// target back to the same endpoint and workdir bytes.
192    #[test]
193    fn serde_round_trips_remote_provenance() {
194        let target = remote_target();
195        let text = serde_json::to_string(&target).unwrap();
196        let back: RepoTarget = serde_json::from_str(&text).unwrap();
197        assert_eq!(target, back);
198    }
199
200    fn container_target() -> RepoTarget {
201        RepoTarget::Container {
202            container: ContainerId::canonical("b".repeat(64)).unwrap(),
203            workdir: PathBuf::from("/work/src"),
204        }
205    }
206
207    /// A container repository is neither SSH-remote nor local: no
208    /// endpoint, no remote-file identity, and `!is_remote()` must never
209    /// read as "openable locally".
210    #[test]
211    fn container_target_is_neither_remote_nor_local() {
212        let target = container_target();
213        assert!(target.is_container());
214        assert!(!target.is_remote());
215        assert_eq!(target.endpoint(), None);
216        assert_eq!(target.remote_file(Path::new("a.rs")), None);
217        assert_eq!(target.workdir(), Path::new("/work/src"));
218        assert_eq!(
219            target.rel_of(Path::new("/work/src/lib.rs")),
220            Some(PathBuf::from("lib.rs"))
221        );
222    }
223
224    /// Container identity is the canonical id plus workdir: a different
225    /// incarnation id is a different repository.
226    #[test]
227    fn container_identity_is_id_plus_workdir() {
228        let a = container_target();
229        let other_id = RepoTarget::Container {
230            container: ContainerId::canonical("c".repeat(64)).unwrap(),
231            workdir: PathBuf::from("/work/src"),
232        };
233        assert_ne!(a, other_id);
234        assert_ne!(
235            a,
236            RepoTarget::Local {
237                workdir: PathBuf::from("/work/src")
238            }
239        );
240    }
241
242    /// The container variant is additive on the replay wire: its name
243    /// is "container", and tapes carrying the pre-container variants
244    /// decode unchanged.
245    #[test]
246    fn serde_round_trips_container_and_decodes_legacy_variants() {
247        let target = container_target();
248        let text = serde_json::to_string(&target).unwrap();
249        assert!(text.contains("\"container\":"), "{text}");
250        let back: RepoTarget = serde_json::from_str(&text).unwrap();
251        assert_eq!(target, back);
252
253        // Legacy string-path form (pre-versioned path_serde) still decodes.
254        let legacy_local: RepoTarget =
255            serde_json::from_str(r#"{"local":{"workdir":"/w"}}"#).unwrap();
256        assert_eq!(
257            legacy_local,
258            RepoTarget::Local {
259                workdir: PathBuf::from("/w")
260            }
261        );
262        let legacy_remote: RepoTarget = serde_json::from_str(
263            r#"{"remote":{"endpoint":"ssh://fixture@box.example:2222","workdir":"/srv/proj"}}"#,
264        )
265        .unwrap();
266        assert_eq!(legacy_remote, remote_target());
267    }
268}