Skip to main content

newgit_core/
export.rs

1use camino::{Utf8Path, Utf8PathBuf};
2
3use crate::error::{NewgitError, Result};
4use crate::tracker::{TrackerDefinition, collect_files};
5
6/// Path-level export filtering. Tracker audience is the default filter and
7/// flags are the override; there is no hunk privacy, no AST rewriting, and
8/// no concealment claim. The filter decides which *files* are copied out of
9/// a workspace — nothing more.
10///
11/// The default is `public`-only, and it fails closed on purpose: a tracker
12/// created without `--audience` is `project-devs`, so anything narrower than
13/// the repo stays behind unless a flag names it.
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct ExportFilter {
16    /// Workspace-relative paths to include regardless of audience.
17    pub includes: Vec<Utf8PathBuf>,
18    /// Workspace-relative paths to drop, applied after everything else.
19    pub excludes: Vec<Utf8PathBuf>,
20}
21
22/// Why one file is in or out of an export.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Reason {
25    /// Git tracks it: source's audience is everyone.
26    Source,
27    /// Owned by a tracker whose audience is `public`.
28    PublicTracker,
29    /// Named by `--include`, overriding a narrower audience.
30    Forced,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ExportedFile {
35    /// Workspace-relative path, which is also its path in the export.
36    pub path: Utf8PathBuf,
37    pub reason: Reason,
38    /// The tracker that owns it, for anything but plain source.
39    pub tracker: Option<String>,
40}
41
42/// One tracker's disposition, so the CLI can say what was left behind and
43/// how to change its mind.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct TrackerDisposition {
46    pub name: String,
47    pub audience: String,
48    pub included: usize,
49    /// Files held back by audience, with no `--include` covering them.
50    pub withheld: Vec<Utf8PathBuf>,
51}
52
53impl TrackerDisposition {
54    pub fn is_public(&self) -> bool {
55        is_public(&self.audience)
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ExportPlan {
61    pub files: Vec<ExportedFile>,
62    pub trackers: Vec<TrackerDisposition>,
63    /// Paths `--exclude` removed, whatever their origin.
64    pub excluded: Vec<Utf8PathBuf>,
65}
66
67impl ExportPlan {
68    pub fn count(&self, reason: Reason) -> usize {
69        self.files
70            .iter()
71            .filter(|file| file.reason == reason)
72            .count()
73    }
74}
75
76/// Audience is a string so `user:<name>` stays expressible; only the exact
77/// value `public` means "everyone may read this".
78pub fn is_public(audience: &str) -> bool {
79    audience == "public"
80}
81
82/// Decide what leaves the workspace. Nothing is copied here — a plan is
83/// worth having on its own, because `--dry-run` and the failure message both
84/// need it before any file moves.
85pub fn plan(
86    workspace: &Utf8Path,
87    source_files: &[Utf8PathBuf],
88    trackers: &[TrackerDefinition],
89    filter: &ExportFilter,
90) -> Result<ExportPlan> {
91    let mut files: Vec<ExportedFile> = Vec::new();
92    let mut excluded: Vec<Utf8PathBuf> = Vec::new();
93
94    for path in source_files {
95        // `ls-files` lists the index; a file deleted but not yet committed is
96        // listed and simply is not there to copy.
97        if !workspace.join(path).is_file() {
98            continue;
99        }
100        if covers(&filter.excludes, path) {
101            excluded.push(path.clone());
102            continue;
103        }
104        files.push(ExportedFile {
105            path: path.clone(),
106            reason: Reason::Source,
107            tracker: None,
108        });
109    }
110
111    let mut dispositions = Vec::new();
112    for tracker in trackers {
113        let public = is_public(&tracker.audience);
114        let mut included = 0;
115        let mut withheld = Vec::new();
116
117        for (relative, _) in collect_files(workspace, &tracker.paths)? {
118            if covers(&filter.excludes, &relative) {
119                excluded.push(relative);
120                continue;
121            }
122            let forced = covers(&filter.includes, &relative);
123            if !public && !forced {
124                withheld.push(relative);
125                continue;
126            }
127            included += 1;
128            files.push(ExportedFile {
129                path: relative,
130                reason: if public {
131                    Reason::PublicTracker
132                } else {
133                    Reason::Forced
134                },
135                tracker: Some(tracker.name.clone()),
136            });
137        }
138
139        dispositions.push(TrackerDisposition {
140            name: tracker.name.clone(),
141            audience: tracker.audience.clone(),
142            included,
143            withheld,
144        });
145    }
146
147    // An `--include` may also name something no tracker owns and Git does not
148    // track — a build output, say. Honoring it keeps the flag meaning one
149    // thing: "this path ships".
150    for include in &filter.includes {
151        for (relative, _) in collect_files(workspace, std::slice::from_ref(include))? {
152            if covers(&filter.excludes, &relative) || files.iter().any(|f| f.path == relative) {
153                continue;
154            }
155            files.push(ExportedFile {
156                path: relative,
157                reason: Reason::Forced,
158                tracker: None,
159            });
160        }
161    }
162
163    files.sort_by(|left, right| left.path.cmp(&right.path));
164    excluded.sort();
165    excluded.dedup();
166
167    Ok(ExportPlan {
168        files,
169        trackers: dispositions,
170        excluded,
171    })
172}
173
174/// Whether any of `paths` is `candidate` or one of its ancestor directories.
175fn covers(paths: &[Utf8PathBuf], candidate: &Utf8Path) -> bool {
176    paths.iter().any(|path| candidate.starts_with(path))
177}
178
179/// A destination must be absent or empty — export writes a fresh repository,
180/// and quietly merging into someone's existing directory is the kind of
181/// surprise that loses work.
182pub fn prepare_destination(destination: &Utf8Path) -> Result<()> {
183    if !destination.exists() {
184        return Ok(());
185    }
186    if !destination.is_dir() {
187        return Err(NewgitError::Unsupported(format!(
188            "{destination} exists and is not a directory"
189        )));
190    }
191    let mut entries =
192        std::fs::read_dir(destination).map_err(|source| NewgitError::io(destination, source))?;
193    if entries.next().is_some() {
194        return Err(NewgitError::Unsupported(format!(
195            "{destination} is not empty; export writes a fresh repository, so pass an empty or \
196             nonexistent path"
197        )));
198    }
199    Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204    use camino::Utf8PathBuf;
205
206    use super::*;
207    use crate::tracker::Storage;
208
209    fn tracker(name: &str, audience: &str, paths: &[&str]) -> TrackerDefinition {
210        TrackerDefinition {
211            name: name.to_owned(),
212            audience: audience.to_owned(),
213            storage: Storage::Local,
214            merge_with_source: false,
215            paths: paths.iter().map(Utf8PathBuf::from).collect(),
216            definition_rev: "sha256:000000000000".to_owned(),
217        }
218    }
219
220    /// A workspace with one source file and two tracker-owned files.
221    fn workspace() -> (tempfile::TempDir, Utf8PathBuf) {
222        let temp = tempfile::tempdir().expect("tempdir");
223        let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
224        std::fs::write(root.join("main.rs"), "fn main() {}").expect("write");
225        std::fs::write(root.join(".env.local"), "SECRET=1").expect("write");
226        std::fs::create_dir_all(root.join("src/generated")).expect("mkdir");
227        std::fs::write(root.join("src/generated/api.ts"), "export {}").expect("write");
228        (temp, root)
229    }
230
231    #[test]
232    fn audience_withholds_by_default_and_flags_override() {
233        let (_guard, root) = workspace();
234        let source = vec![Utf8PathBuf::from("main.rs")];
235        let trackers = [
236            tracker("runtime-env", "user", &[".env.local"]),
237            tracker("generated-sdk", "public", &["src/generated"]),
238        ];
239
240        let default = plan(&root, &source, &trackers, &ExportFilter::default()).expect("plan");
241        let paths: Vec<&str> = default.files.iter().map(|f| f.path.as_str()).collect();
242        assert_eq!(paths, ["main.rs", "src/generated/api.ts"]);
243        assert_eq!(default.count(Reason::Source), 1);
244        assert_eq!(default.count(Reason::PublicTracker), 1);
245
246        let env = default
247            .trackers
248            .iter()
249            .find(|t| t.name == "runtime-env")
250            .expect("runtime-env");
251        assert_eq!(env.withheld, [Utf8PathBuf::from(".env.local")]);
252        assert_eq!(env.included, 0);
253
254        // --include overrides the audience for exactly the named path.
255        let forced = plan(
256            &root,
257            &source,
258            &trackers,
259            &ExportFilter {
260                includes: vec![Utf8PathBuf::from(".env.local")],
261                excludes: Vec::new(),
262            },
263        )
264        .expect("plan");
265        assert_eq!(forced.count(Reason::Forced), 1);
266        assert!(
267            forced
268                .trackers
269                .iter()
270                .find(|t| t.name == "runtime-env")
271                .expect("runtime-env")
272                .withheld
273                .is_empty()
274        );
275    }
276
277    #[test]
278    fn exclude_wins_over_source_and_include() {
279        let (_guard, root) = workspace();
280        let source = vec![Utf8PathBuf::from("main.rs")];
281        let trackers = [tracker("generated-sdk", "public", &["src/generated"])];
282
283        let filtered = plan(
284            &root,
285            &source,
286            &trackers,
287            &ExportFilter {
288                includes: vec![Utf8PathBuf::from("src/generated")],
289                excludes: vec![
290                    Utf8PathBuf::from("main.rs"),
291                    Utf8PathBuf::from("src/generated"),
292                ],
293            },
294        )
295        .expect("plan");
296
297        assert!(
298            filtered.files.is_empty(),
299            "exclude is applied last and wins"
300        );
301        assert_eq!(
302            filtered.excluded,
303            [
304                Utf8PathBuf::from("main.rs"),
305                Utf8PathBuf::from("src/generated/api.ts")
306            ]
307        );
308    }
309
310    #[test]
311    fn destination_must_be_absent_or_empty() {
312        let (_guard, root) = workspace();
313        assert!(prepare_destination(&root.join("fresh")).is_ok());
314        std::fs::create_dir_all(root.join("empty")).expect("mkdir");
315        assert!(prepare_destination(&root.join("empty")).is_ok());
316        assert!(matches!(
317            prepare_destination(&root),
318            Err(NewgitError::Unsupported(_))
319        ));
320    }
321}