Skip to main content

strop_git/
target.rs

1//! The typed repository boundary (0036 RW8): every Git request names
2//! the machine its worktree lives on. A local workdir is openable with
3//! libgit2 and local `git`; a remote workdir is bytes on another host
4//! and only bounded remote `git` commands can read it. Keeping the two
5//! in one enum — instead of a bare path that could mean either — makes
6//! "treated a remote path as local" a type error instead of a bug.
7//!
8//! The same boundary carries provenance through the memory surfaces:
9//! a log row's dive, a commit's file list and a delta's `]f` step all
10//! replay the [`RepoTarget`] they were launched with, so a remote
11//! surface can never answer from the local cwd.
12
13use std::path::{Path, PathBuf};
14
15use strop_workspace::{RemoteEndpoint, RemoteFile};
16
17/// Where a Git query runs. Exactly two real backends exist (0036);
18/// there is deliberately no provider trait behind them.
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum RepoTarget {
22    /// A worktree on this machine: libgit2 and local `git` apply.
23    Local {
24        #[serde(with = "strop_core::path_serde")]
25        workdir: PathBuf,
26    },
27    /// A worktree on `endpoint`. The workdir names a path on the
28    /// remote host — it is never a valid local path, and no libgit2
29    /// handle may be opened against it.
30    Remote {
31        endpoint: RemoteEndpoint,
32        #[serde(with = "strop_core::path_serde")]
33        workdir: PathBuf,
34    },
35}
36
37impl RepoTarget {
38    /// The repository root as native bytes — locally openable only for
39    /// [`RepoTarget::Local`]; callers that need to open it must match
40    /// on the variant first.
41    pub fn workdir(&self) -> &Path {
42        match self {
43            Self::Local { workdir } | Self::Remote { workdir, .. } => workdir,
44        }
45    }
46
47    /// The remote-file identity for a repo-relative path (remote
48    /// repositories only): endpoint plus native path, the identity an
49    /// open request routes by. A local repository has no remote file.
50    pub fn remote_file(&self, rel: &Path) -> Option<RemoteFile> {
51        match self {
52            Self::Local { .. } => None,
53            Self::Remote { endpoint, workdir } => {
54                RemoteFile::from_path(endpoint.clone(), workdir.join(rel)).ok()
55            }
56        }
57    }
58
59    pub fn is_remote(&self) -> bool {
60        matches!(self, Self::Remote { .. })
61    }
62
63    /// Repo-relative path for a path inside this repository — the
64    /// remote flavor strips the *remote* workdir. `None` is the typed
65    /// refusal for a path that is not inside the repository at all.
66    pub fn rel_of(&self, path: &Path) -> Option<PathBuf> {
67        path.strip_prefix(self.workdir())
68            .ok()
69            .map(|rel| rel.to_path_buf())
70    }
71
72    /// The absolute path of a repo-relative path *on the machine this
73    /// repository lives on*.
74    pub fn abs_of(&self, rel: &Path) -> PathBuf {
75        self.workdir().join(rel)
76    }
77
78    pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
79        match self {
80            Self::Local { .. } => None,
81            Self::Remote { endpoint, .. } => Some(endpoint),
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn remote_target() -> RepoTarget {
91        RepoTarget::Remote {
92            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
93            workdir: PathBuf::from("/srv/proj"),
94        }
95    }
96
97    /// The boundary is identity: equal targets mean the same repository
98    /// on the same machine; a different port is a different repository.
99    #[test]
100    fn remote_identity_is_endpoint_plus_workdir() {
101        let a = remote_target();
102        let same = RepoTarget::Remote {
103            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
104            workdir: PathBuf::from("/srv/proj"),
105        };
106        let other_port = RepoTarget::Remote {
107            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2223").unwrap(),
108            workdir: PathBuf::from("/srv/proj"),
109        };
110        let other_dir = RepoTarget::Remote {
111            endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
112            workdir: PathBuf::from("/other"),
113        };
114        assert_eq!(a, same);
115        assert_ne!(a, other_port);
116        assert_ne!(a, other_dir);
117        assert_ne!(
118            a,
119            RepoTarget::Local {
120                workdir: PathBuf::from("/srv/proj")
121            }
122        );
123    }
124
125    /// A remote path is never relative to the local machine: rel_of
126    /// strips the REMOTE workdir, and the same spelling stays a local
127    /// path under the local variant — the two never interchange.
128    #[test]
129    fn rel_of_strips_the_owning_workdir() {
130        let target = remote_target();
131        assert_eq!(
132            target.rel_of(Path::new("/srv/proj/src/main.rs")),
133            Some(PathBuf::from("src/main.rs"))
134        );
135        assert_eq!(target.rel_of(Path::new("/home/me/src/main.rs")), None);
136    }
137
138    /// remote_file rebuilds the canonical open identity: same endpoint,
139    /// native path under the remote workdir — what a dive's source open
140    /// routes to Main with.
141    #[test]
142    fn remote_file_carries_endpoint_and_native_path() {
143        let target = remote_target();
144        let file = target.remote_file(Path::new("src/a b.rs")).unwrap();
145        assert_eq!(
146            file.endpoint(),
147            &RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap()
148        );
149        assert_eq!(file.path(), Path::new("/srv/proj/src/a b.rs"));
150        assert!(target.remote_file(Path::new("x")).is_some());
151    }
152
153    /// Local repositories have no remote identity and no endpoint —
154    /// refusal, not a local stand-in.
155    #[test]
156    fn local_target_has_no_remote_identity() {
157        let target = RepoTarget::Local {
158            workdir: PathBuf::from("/w"),
159        };
160        assert_eq!(target.endpoint(), None);
161        assert!(!target.is_remote());
162        assert_eq!(target.remote_file(Path::new("a.rs")), None);
163    }
164
165    /// Provenance survives the replay wire: serde round-trips a remote
166    /// target back to the same endpoint and workdir bytes.
167    #[test]
168    fn serde_round_trips_remote_provenance() {
169        let target = remote_target();
170        let text = serde_json::to_string(&target).unwrap();
171        let back: RepoTarget = serde_json::from_str(&text).unwrap();
172        assert_eq!(target, back);
173    }
174}