Skip to main content

speccoder_runtime/
include.rs

1//! `[worktree] include` and `.worktreeinclude` (Claude Code's file,
2//! `.gitignore` syntax): git-ignored files of the person's checkout — a
3//! `.env`, a local config — copied into each worktree speccoder runs checks
4//! in, to the same relative path.
5//!
6//! Only files git ignores in the checkout are copied, never a tracked one;
7//! regular files only, never through a symlink out of the checkout, never
8//! over a file the worktree tracks, and only inside the worktree. A copied
9//! secret file's values are masked in everything speccoder shows or hands
10//! on ([`crate::secrets`]), a commit never holds a copied file, and an
11//! attempt whose diff adds one of those values is refused.
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::git::{Git, nul_fields};
18
19/// Claude Code's file of patterns, read beside `[worktree] include`.
20pub const FILE: &str = ".worktreeinclude";
21
22/// The files the patterns name in the checkout's project directory that git
23/// ignores there, project-relative: `[worktree] include`'s patterns and
24/// `.worktreeinclude`'s, together. Regular files only, inside the checkout.
25#[must_use]
26pub fn matched(checkout: &Path, patterns: &[String]) -> Vec<String> {
27    let file = checkout.join(FILE);
28    let from_file = file.is_file();
29    if patterns.is_empty() && !from_file {
30        return Vec::new();
31    }
32    let g = Git::at(checkout);
33    let mut args: Vec<String> = ["ls-files", "-z", "--others", "--ignored"]
34        .map(str::to_owned)
35        .to_vec();
36    args.extend(patterns.iter().map(|p| format!("--exclude={p}")));
37    if from_file {
38        args.push(format!("--exclude-from={}", file.display()));
39    }
40    args.extend(["--".to_owned(), ".".to_owned()]);
41    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
42    let named = g.run(&argv).map(|o| nul_fields(&o)).unwrap_or_default();
43    if named.is_empty() {
44        return Vec::new();
45    }
46    // Of those, what git ignores in the checkout (never a tracked file:
47    // `--others` lists none).
48    let mut input = Vec::new();
49    for n in &named {
50        input.extend_from_slice(n.as_bytes());
51        input.push(0);
52    }
53    let ignored = g
54        .run_with_stdin(&["check-ignore", "-z", "--stdin"], &input)
55        .map(|o| nul_fields(&o))
56        .unwrap_or_default();
57    let home = std::fs::canonicalize(checkout).unwrap_or_else(|_| checkout.to_path_buf());
58    let mut out: Vec<String> = named
59        .into_iter()
60        .filter(|n| ignored.contains(n))
61        .filter(|n| {
62            let p = checkout.join(n);
63            std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_file())
64                && std::fs::canonicalize(&p).is_ok_and(|c| c.starts_with(&home))
65        })
66        .collect();
67    out.sort();
68    out.dedup();
69    out
70}
71
72/// What a worktree holds copied from the checkout.
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Record {
75    /// The copied files, relative to the worktree's root (as git names them).
76    #[serde(default)]
77    pub files: Vec<String>,
78}
79
80/// Where a worktree's record of copies is kept: beside it, never inside.
81#[must_use]
82pub fn record_path(worktree: &Path) -> PathBuf {
83    let name = worktree
84        .file_name()
85        .map(|n| n.to_string_lossy().into_owned())
86        .unwrap_or_default();
87    worktree.with_file_name(format!("{name}.include.json"))
88}
89
90/// The worktree's record of copies (none: nothing copied).
91#[must_use]
92pub fn load(worktree: &Path) -> Record {
93    std::fs::read_to_string(record_path(worktree))
94        .ok()
95        .and_then(|t| serde_json::from_str(&t).ok())
96        .unwrap_or_default()
97}
98
99/// Forgets a worktree's record of copies (before the worktree is removed).
100pub fn forget(worktree: &Path) {
101    let _ = std::fs::remove_file(record_path(worktree));
102}
103
104/// What copying did.
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct Copied {
107    /// Every file the worktree holds copied, worktree-root-relative.
108    pub files: Vec<String>,
109    /// Those copied now (new, or changed in the checkout).
110    pub now: Vec<String>,
111    /// Why a matched file was not copied, one line each.
112    pub skipped: Vec<String>,
113}
114
115/// Copies the files [`matched`] names from `checkout` (the project's
116/// directory there) to the same paths under `project` (its directory in
117/// the worktree `worktree`): new ones, and again those whose content
118/// changed. Registers each copied secret file's values for masking.
119///
120/// # Errors
121///
122/// The record of copies could not be written.
123pub fn copy(
124    worktree: &Path,
125    project: &Path,
126    checkout: &Path,
127    patterns: &[String],
128) -> Result<Copied, String> {
129    let names = matched(checkout, patterns);
130    let mut out = Copied::default();
131    let root =
132        std::fs::canonicalize(worktree).map_err(|e| format!("{}: {e}", worktree.display()))?;
133    let inner = std::fs::canonicalize(project)
134        .ok()
135        .and_then(|p| p.strip_prefix(&root).ok().map(Path::to_path_buf))
136        .unwrap_or_default();
137    // Never over a file the worktree tracks.
138    let tracked: Vec<String> = if names.is_empty() {
139        Vec::new()
140    } else {
141        let mut args = vec!["ls-files", "-z", "--"];
142        args.extend(names.iter().map(String::as_str));
143        Git::at(project)
144            .run(&args)
145            .map(|o| nul_fields(&o))
146            .unwrap_or_default()
147    };
148    for n in &names {
149        if tracked.contains(n) {
150            out.skipped
151                .push(format!("{n}: tracked in the worktree, left as committed"));
152            continue;
153        }
154        let rel = inner.join(n);
155        match copy_one(&root, &rel, &checkout.join(n)) {
156            Ok(changed) => {
157                let shown = rel.to_string_lossy().into_owned();
158                if changed {
159                    out.now.push(shown.clone());
160                }
161                out.files.push(shown);
162            }
163            Err(why) => out.skipped.push(format!("{n}: {why}")),
164        }
165    }
166    // A copy no longer included goes (only one this made, the worktree's
167    // own file, never a tracked one): left, it would be neither recorded
168    // nor masked.
169    let old = load(worktree);
170    for f in old.files.iter().filter(|f| !out.files.contains(f)) {
171        if own_file(&root, Path::new(f))
172            && !Git::at(&root)
173                .run(&["ls-files", "-z", "--", f])
174                .is_ok_and(|o| !o.is_empty())
175        {
176            let _ = std::fs::remove_file(root.join(f));
177        }
178    }
179    let record = Record {
180        files: out.files.clone(),
181    };
182    if record != old {
183        let path = record_path(worktree);
184        let text = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
185        if record.files.is_empty() {
186            let _ = std::fs::remove_file(&path);
187        } else {
188            std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
189        }
190    }
191    register(worktree);
192    Ok(out)
193}
194
195/// Whether `root/rel` is a regular file reached without a symlink.
196fn own_file(root: &Path, rel: &Path) -> bool {
197    let mut at = root.to_path_buf();
198    for c in rel.components() {
199        let std::path::Component::Normal(c) = c else {
200            return false;
201        };
202        at.push(c);
203        if std::fs::symlink_metadata(&at).map_or(true, |m| m.file_type().is_symlink()) {
204            return false;
205        }
206    }
207    std::fs::symlink_metadata(&at).is_ok_and(|m| m.is_file())
208}
209
210/// Copies `source` to `root/rel`, every directory on the way the
211/// worktree's own (made, never followed through a link); `true` when the
212/// file was written.
213fn copy_one(root: &Path, rel: &Path, source: &Path) -> Result<bool, String> {
214    let mut at = root.to_path_buf();
215    let parts: Vec<_> = rel.components().collect();
216    let Some((name, dirs)) = parts.split_last() else {
217        return Err("not a file".to_owned());
218    };
219    for d in dirs {
220        let std::path::Component::Normal(d) = d else {
221            return Err("not a plain path".to_owned());
222        };
223        at.push(d);
224        match std::fs::symlink_metadata(&at) {
225            Ok(m) if m.file_type().is_symlink() => {
226                return Err(format!("`{}` in the worktree is a link", at.display()));
227            }
228            Ok(m) if m.is_dir() => {}
229            Ok(_) => return Err(format!("`{}` in the worktree is a file", at.display())),
230            Err(_) => std::fs::create_dir(&at).map_err(|e| format!("{}: {e}", at.display()))?,
231        }
232    }
233    let std::path::Component::Normal(name) = name else {
234        return Err("not a plain path".to_owned());
235    };
236    let dest = at.join(name);
237    let bytes = std::fs::read(source).map_err(|e| format!("{}: {e}", source.display()))?;
238    match std::fs::symlink_metadata(&dest) {
239        // A link there (an attempt can make one) goes — itself, never
240        // what it points to.
241        Ok(m) if m.file_type().is_symlink() => {
242            std::fs::remove_file(&dest).map_err(|e| format!("{}: {e}", dest.display()))?;
243        }
244        Ok(m) if m.is_dir() => return Err("a directory in the worktree".to_owned()),
245        Ok(_) if std::fs::read(&dest).is_ok_and(|b| b == bytes) => return Ok(false),
246        _ => {}
247    }
248    std::fs::write(&dest, &bytes).map_err(|e| format!("{}: {e}", dest.display()))?;
249    Ok(true)
250}
251
252/// What the worktree's copied secret files (`.env*` and the rest the
253/// model may not read) give to mask, as `(NAME, value)`, read from the
254/// copies ([`crate::secrets::to_mask`]).
255#[must_use]
256pub fn values(worktree: &Path) -> Vec<(String, String)> {
257    let protected = crate::tools::paths::Protected::default();
258    load(worktree)
259        .files
260        .iter()
261        .filter(|f| protected.is_secret(f))
262        .filter_map(|f| {
263            let name = Path::new(f).file_name()?.to_string_lossy().into_owned();
264            let text = std::fs::read_to_string(worktree.join(f)).ok()?;
265            Some(crate::secrets::to_mask(&name, &text))
266        })
267        .flatten()
268        .collect()
269}
270
271/// Masks the worktree's copied values from now on ([`values`]).
272pub fn register(worktree: &Path) {
273    for (k, v) in values(worktree) {
274        crate::secrets::register(&k, &v);
275    }
276}
277
278/// The pathspecs keeping each copied file out of what `git add` stages in
279/// the worktree at `top`, whatever the ignore rules say: one for each that
280/// git does not ignore there now (an attempt can un-ignore one). An
281/// ignored one needs none — and naming it would make `git add` fail.
282#[must_use]
283pub fn pathspecs(top: &Path) -> Vec<String> {
284    let files = load(top).files;
285    if files.is_empty() {
286        return Vec::new();
287    }
288    let mut input = Vec::new();
289    for f in &files {
290        input.extend_from_slice(f.as_bytes());
291        input.push(0);
292    }
293    let ignored = Git::at(top)
294        .run_with_stdin(&["check-ignore", "-z", "--stdin"], &input)
295        .map(|o| nul_fields(&o))
296        .unwrap_or_default();
297    files
298        .iter()
299        .filter(|f| !ignored.contains(f))
300        .map(|f| format!(":(exclude,top,literal){f}"))
301        .collect()
302}
303
304/// The variable whose copied value a patch's added lines hold.
305#[must_use]
306pub fn added_secret(patch: &str) -> Option<String> {
307    patch
308        .lines()
309        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
310        .find_map(crate::secrets::found_in)
311}
312
313/// Refuses a commit in the worktree at `top` whose index holds a file
314/// copied from the checkout (unstaged then) or whose staged change adds a
315/// copied secret value. Nothing is committed then.
316///
317/// # Errors
318///
319/// What was refused, naming the file or the variable — never a value.
320pub fn check_staged(top: &Path) -> Result<(), String> {
321    let copied = load(top).files;
322    if copied.is_empty() {
323        return Ok(());
324    }
325    register(top);
326    let g = Git::at(top);
327    let staged = g
328        .run(&["diff", "--cached", "--name-only", "-z", "--no-renames"])
329        .map(|o| nul_fields(&o))
330        .map_err(|e| e.0)?;
331    let held: Vec<&String> = staged.iter().filter(|p| copied.contains(p)).collect();
332    if !held.is_empty() {
333        let mut reset = vec!["reset", "-q", "--"];
334        reset.extend(held.iter().map(|p| p.as_str()));
335        if g.run(&reset).is_err() {
336            let mut rm = vec!["rm", "--cached", "-q", "--ignore-unmatch", "--"];
337            rm.extend(held.iter().map(|p| p.as_str()));
338            let _ = g.run(&rm);
339        }
340        return Err(format!(
341            "refused: {} is copied from your checkout ([worktree] include) and never committed; unstaged, nothing committed",
342            held.iter()
343                .map(|p| format!("`{p}`"))
344                .collect::<Vec<_>>()
345                .join(", ")
346        ));
347    }
348    let patch = g
349        .run(&["diff", "--cached", "-U0", "--no-color", "--no-ext-diff"])
350        .map(|o| String::from_utf8_lossy(&o).into_owned())
351        .map_err(|e| e.0)?;
352    if let Some(name) = added_secret(&patch) {
353        return Err(format!(
354            "refused: the change adds the value of {name}, copied from your checkout ([worktree] include); nothing committed"
355        ));
356    }
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn git(dir: &Path, args: &[&str]) {
365        let ok = std::process::Command::new("git")
366            .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
367            .args([
368                "-c",
369                "commit.gpgsign=false",
370                "-c",
371                "init.defaultBranch=main",
372            ])
373            .args(args)
374            .current_dir(dir)
375            .status()
376            .is_ok_and(|s| s.success());
377        assert!(ok, "git {args:?}");
378    }
379
380    /// A checkout: `.env` and `local/*.json` ignored, `app.json` tracked,
381    /// and a worktree of it.
382    fn checkout() -> (tempfile::TempDir, PathBuf) {
383        let dir = tempfile::tempdir().unwrap();
384        let r = dir.path().join("checkout");
385        std::fs::create_dir_all(r.join("local")).unwrap();
386        std::fs::write(r.join(".gitignore"), ".env\nlocal/\nnotes.txt\n").unwrap();
387        std::fs::write(r.join("app.json"), "{}").unwrap();
388        git(&r, &["init", "-q"]);
389        git(&r, &["add", "-A"]);
390        git(&r, &["commit", "-qm", "init"]);
391        std::fs::write(r.join(".env"), "API_TOKEN=include-test-Qw7 value\n").unwrap();
392        std::fs::write(r.join("local/db.json"), "{\"pw\": 1}").unwrap();
393        std::fs::write(r.join("notes.txt"), "x").unwrap();
394        std::fs::write(r.join("untracked.json"), "x").unwrap();
395        let wt = dir.path().join("wt");
396        git(
397            &r,
398            &[
399                "worktree",
400                "add",
401                "-q",
402                "--detach",
403                &wt.display().to_string(),
404            ],
405        );
406        (dir, r)
407    }
408
409    #[test]
410    fn only_ignored_files_the_patterns_name_are_matched() {
411        let (_d, r) = checkout();
412        let pats = |p: &[&str]| p.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
413        assert!(matched(&r, &[]).is_empty());
414        assert_eq!(matched(&r, &pats(&[".env"])), [".env"]);
415        // Tracked (app.json) and untracked-but-not-ignored never.
416        assert_eq!(matched(&r, &pats(&["*.json"])), ["local/db.json"]);
417        // `.worktreeinclude` joins in.
418        std::fs::write(r.join(FILE), "notes.txt\n# a comment\n").unwrap();
419        assert_eq!(matched(&r, &pats(&[".env"])), [".env", "notes.txt"]);
420        // A symlink is never followed out of the checkout.
421        let outside = tempfile::tempdir().unwrap();
422        std::fs::write(outside.path().join("secret"), "x").unwrap();
423        std::fs::remove_file(r.join("notes.txt")).unwrap();
424        std::os::unix::fs::symlink(outside.path().join("secret"), r.join("notes.txt")).unwrap();
425        assert_eq!(matched(&r, &pats(&[".env"])), [".env"]);
426    }
427
428    #[test]
429    fn copies_land_in_the_worktree_only_and_follow_changes() {
430        let (d, r) = checkout();
431        let wt = d.path().join("wt");
432        let pats = vec![".env".to_owned(), "local/".to_owned()];
433        let c = copy(&wt, &wt, &r, &pats).unwrap();
434        assert_eq!(c.files, [".env", "local/db.json"]);
435        assert_eq!(c.now, c.files);
436        assert_eq!(load(&wt).files, c.files);
437        assert!(
438            std::fs::read_to_string(wt.join(".env"))
439                .unwrap()
440                .contains("Qw7")
441        );
442        // Unchanged: nothing copied again; changed: copied again.
443        assert!(copy(&wt, &wt, &r, &pats).unwrap().now.is_empty());
444        std::fs::write(r.join(".env"), "API_TOKEN=include-test-Qw8 value\n").unwrap();
445        assert_eq!(copy(&wt, &wt, &r, &pats).unwrap().now, [".env"]);
446        // A worktree directory that is a link is never written through.
447        let elsewhere = tempfile::tempdir().unwrap();
448        std::fs::remove_dir_all(wt.join("local")).unwrap();
449        std::os::unix::fs::symlink(elsewhere.path(), wt.join("local")).unwrap();
450        let c = copy(&wt, &wt, &r, &pats).unwrap();
451        assert_eq!(c.files, [".env"]);
452        assert!(
453            c.skipped[0].starts_with("local/db.json: "),
454            "{:?}",
455            c.skipped
456        );
457        assert!(
458            std::fs::read_dir(elsewhere.path())
459                .unwrap()
460                .next()
461                .is_none()
462        );
463        // No longer included: the copy goes.
464        let c = copy(&wt, &wt, &r, &[]).unwrap();
465        assert!(c.files.is_empty());
466        assert!(!wt.join(".env").exists());
467        assert!(load(&wt).files.is_empty());
468        assert!(r.join(".env").is_file(), "the checkout's stays");
469        copy(&wt, &wt, &r, &pats).unwrap();
470        // A file the worktree tracks stays as committed.
471        std::fs::write(wt.join(".gitignore"), "local/\n").unwrap();
472        git(&wt, &["add", "-f", ".env", ".gitignore"]);
473        git(&wt, &["commit", "-qm", "track it"]);
474        std::fs::write(wt.join(".env"), "MINE=1\n").unwrap();
475        let c = copy(&wt, &wt, &r, &pats).unwrap();
476        assert!(
477            c.skipped.iter().any(|s| s.starts_with(".env: tracked")),
478            "{:?}",
479            c.skipped
480        );
481        assert_eq!(
482            std::fs::read_to_string(wt.join(".env")).unwrap(),
483            "MINE=1\n"
484        );
485    }
486
487    #[test]
488    fn a_staged_copy_or_a_staged_value_is_refused() {
489        let (d, r) = checkout();
490        let wt = d.path().join("wt");
491        copy(&wt, &wt, &r, &[".env".to_owned()]).unwrap();
492        assert_eq!(
493            values(&wt),
494            [("API_TOKEN".to_owned(), "include-test-Qw7 value".to_owned())]
495        );
496        assert!(check_staged(&wt).is_ok());
497        git(&wt, &["add", "-f", ".env"]);
498        let e = check_staged(&wt).unwrap_err();
499        assert!(e.contains("`.env` is copied from your checkout"), "{e}");
500        let staged = std::process::Command::new("git")
501            .args(["diff", "--cached", "--name-only"])
502            .current_dir(&wt)
503            .output()
504            .unwrap();
505        assert!(staged.stdout.is_empty(), "unstaged");
506        std::fs::write(wt.join("app.json"), "{\"t\": \"include-test-Qw7 value\"}").unwrap();
507        git(&wt, &["add", "app.json"]);
508        let e = check_staged(&wt).unwrap_err();
509        assert!(e.contains("adds the value of API_TOKEN"), "{e}");
510        assert!(!e.contains("Qw7"));
511        // Un-ignored by the attempt: kept out by a pathspec.
512        assert!(pathspecs(&wt).is_empty(), "ignored: nothing to name");
513        std::fs::write(wt.join(".gitignore"), "local/\n").unwrap();
514        assert_eq!(pathspecs(&wt), [":(exclude,top,literal).env"]);
515    }
516}