Skip to main content

newgit_core/
source.rs

1use std::process::Command;
2
3use camino::{Utf8Path, Utf8PathBuf};
4
5use crate::config::SourceSubstrate;
6use crate::error::{NewgitError, Result};
7
8/// Shell-out driver for the source tracker. Everything goes through Git's
9/// public interface — never into `.git` directly — so it works identically
10/// against a clone or, later, a projection.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct GitSource {
13    root: Utf8PathBuf,
14}
15
16impl GitSource {
17    pub fn open(root: &Utf8Path, substrate: SourceSubstrate) -> Result<Self> {
18        if root.join(".git").exists() {
19            return Ok(Self {
20                root: root.to_path_buf(),
21            });
22        }
23        match substrate {
24            SourceSubstrate::Jj => Err(NewgitError::Unsupported(format!(
25                "{root} is a jj repository without a colocated .git; v1 drives source through \
26                 Git — recreate it with `jj git init --colocate`"
27            ))),
28            SourceSubstrate::Git => Err(NewgitError::NotAGitRepo(root.to_path_buf())),
29        }
30    }
31
32    pub fn root(&self) -> &Utf8Path {
33        &self.root
34    }
35
36    /// Whether the store repo's gitignore rules cover `path` (which need not
37    /// exist yet) — used to enforce the tracker-path invariant.
38    pub fn is_ignored(&self, path: &str) -> Result<bool> {
39        let args = ["-C", self.root.as_str(), "check-ignore", "-q", "--", path];
40        let output = Command::new("git")
41            .args(args)
42            .output()
43            .map_err(|source| spawn_error(&args, &source))?;
44        Ok(output.status.success())
45    }
46
47    /// Whether `path` is tracked by the store repo — a tracker path that is
48    /// also in Git history is dual-tracked, deliberately or not.
49    pub fn is_tracked(&self, path: &str) -> Result<bool> {
50        self.git(&["ls-files", "--", path])
51            .map(|stdout| !stdout.is_empty())
52    }
53
54    pub fn branch_exists(&self, name: &str) -> Result<bool> {
55        let args = [
56            "-C",
57            self.root.as_str(),
58            "rev-parse",
59            "--verify",
60            "--quiet",
61            &format!("refs/heads/{name}"),
62        ];
63        let output = Command::new("git")
64            .args(args)
65            .output()
66            .map_err(|source| spawn_error(&args, &source))?;
67        Ok(output.status.success())
68    }
69
70    pub fn create_branch(&self, name: &str, base: &str) -> Result<()> {
71        self.git(&["branch", "--", name, base]).map(|_| ())
72    }
73
74    pub fn rev_parse(&self, revision: &str) -> Result<String> {
75        self.git(&["rev-parse", "--verify", &format!("{revision}^{{commit}}")])
76            .map_err(|error| {
77                if revision == "HEAD" {
78                    NewgitError::Unsupported(
79                        "the store repository has no commits yet; make an initial commit before \
80                         spawning"
81                            .to_owned(),
82                    )
83                } else {
84                    error
85                }
86            })
87    }
88
89    /// Materialize `branch` as a full standalone clone at `destination`.
90    /// A local-path clone hardlinks objects; never `--shared`/`--reference`.
91    pub fn clone_to(&self, branch: &str, destination: &Utf8Path) -> Result<()> {
92        run_git(&[
93            "clone",
94            "--quiet",
95            "--branch",
96            branch,
97            "--",
98            self.root.as_str(),
99            destination.as_str(),
100        ])
101        .map(|_| ())
102    }
103
104    /// Fetch a ref from another repository (typically a workspace clone)
105    /// into the store. Fetch, never push: the store pulls commits in when a
106    /// checkpoint blesses them, and workspaces stay passive.
107    pub fn fetch_ref(&self, from: &Utf8Path, remote_ref: &str, local_ref: &str) -> Result<()> {
108        self.git(&[
109            "fetch",
110            "--quiet",
111            "--no-write-fetch-head",
112            "--",
113            from.as_str(),
114            &format!("+{remote_ref}:{local_ref}"),
115        ])
116        .map(|_| ())
117    }
118
119    pub fn update_ref(&self, name: &str, rev: &str) -> Result<()> {
120        self.git(&["update-ref", name, rev]).map(|_| ())
121    }
122
123    /// Every ref under a namespace, e.g. `refs/newgit/checkpoints/<slug>`.
124    pub fn refs_under(&self, prefix: &str) -> Result<Vec<String>> {
125        let listing = self.git(&["for-each-ref", "--format=%(refname)", prefix])?;
126        Ok(listing
127            .lines()
128            .map(str::trim)
129            .filter(|line| !line.is_empty())
130            .map(ToOwned::to_owned)
131            .collect())
132    }
133
134    pub fn delete_ref(&self, name: &str) -> Result<()> {
135        self.git(&["update-ref", "-d", name]).map(|_| ())
136    }
137
138    /// Resolve a ref to a commit, when it exists.
139    pub fn ref_rev(&self, name: &str) -> Option<String> {
140        self.git(&[
141            "rev-parse",
142            "--verify",
143            "--quiet",
144            &format!("{name}^{{commit}}"),
145        ])
146        .ok()
147        .filter(|rev| !rev.is_empty())
148    }
149
150    pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
151        let args = [
152            "-C",
153            self.root.as_str(),
154            "merge-base",
155            "--is-ancestor",
156            ancestor,
157            descendant,
158        ];
159        let output = Command::new("git")
160            .args(args)
161            .output()
162            .map_err(|source| spawn_error(&args, &source))?;
163        Ok(output.status.success())
164    }
165
166    /// Live HEAD of a workspace clone, short form.
167    pub fn workspace_short_head(workspace: &Utf8Path) -> Result<String> {
168        run_git(&["-C", workspace.as_str(), "rev-parse", "--short", "HEAD"])
169    }
170
171    /// Live HEAD of a workspace clone, full form.
172    pub fn workspace_head(workspace: &Utf8Path) -> Result<String> {
173        run_git(&["-C", workspace.as_str(), "rev-parse", "HEAD"])
174    }
175
176    /// Committed content of one path in a workspace clone: the blob at HEAD,
177    /// not what is on disk. `None` when HEAD has no such path.
178    ///
179    /// This is what a render substitutes into — see [`crate::render::apply`].
180    /// Read raw, never through [`run_git`]: that trims, which is right for a
181    /// rev and silently destructive for file content — it would drop the
182    /// file's trailing newline on every render.
183    pub fn workspace_show_head(workspace: &Utf8Path, path: &Utf8Path) -> Result<Option<String>> {
184        let args = ["-C", workspace.as_str(), "show", &format!("HEAD:{path}")];
185        let output = Command::new("git")
186            .args(args)
187            .output()
188            .map_err(|source| spawn_error(&args, &source))?;
189        // `git show` fails the same way for "no such path at HEAD" as for a
190        // broken repo; the caller has already established the latter is not
191        // the case, and treats absence as "nothing committed to render from".
192        if !output.status.success() {
193            return Ok(None);
194        }
195        // Strict rather than lossy: rendering into a file newgit cannot read
196        // as text would write back mojibake where the project's bytes were.
197        match String::from_utf8(output.stdout) {
198            Ok(contents) => Ok(Some(contents)),
199            Err(_) => Err(NewgitError::Unsupported(format!(
200                "`{path}` is not valid UTF-8 at HEAD; a render substitutes text"
201            ))),
202        }
203    }
204
205    /// Mark paths `--skip-worktree` in a workspace clone, so this instance's
206    /// rendered values never show as a modification and cannot be committed
207    /// by an agent running `git add -A`.
208    ///
209    /// The counterpart of `.git/info/exclude` for tracker paths: newgit does
210    /// not control the Git an agent runs, so what must not be committable has
211    /// to be made so by construction. v1's stand-in for the projection a v2
212    /// materializer does properly.
213    pub fn workspace_skip_worktree(workspace: &Utf8Path, paths: &[Utf8PathBuf]) -> Result<()> {
214        if paths.is_empty() {
215            return Ok(());
216        }
217        let mut args: Vec<&str> = vec![
218            "-C",
219            workspace.as_str(),
220            "update-index",
221            "--skip-worktree",
222            "--",
223        ];
224        args.extend(paths.iter().map(|path| path.as_str()));
225        run_git(&args).map(|_| ())
226    }
227
228    /// Snapshot uncommitted and untracked (non-ignored) workspace state as a
229    /// dangling commit on top of HEAD, without touching HEAD, the real
230    /// index, or the worktree. Returns `None` when the worktree is clean.
231    ///
232    /// Mechanics: a throwaway `GIT_INDEX_FILE` seeded from HEAD, `git add -A`
233    /// into it, `git write-tree`, and `git commit-tree` — all public
234    /// interface, nothing reaches into `.git` internals.
235    ///
236    /// `rendered` paths are marked skip-worktree *in the throwaway index*.
237    /// The real index carries that bit already, but a fresh index seeded from
238    /// HEAD does not, so without this `git add -A` would sweep the instance's
239    /// rendered ports into the checkpoint — the one place skip-worktree does
240    /// not protect on its own.
241    pub fn workspace_dirty_commit(
242        workspace: &Utf8Path,
243        message: &str,
244        rendered: &[Utf8PathBuf],
245    ) -> Result<Option<String>> {
246        let scratch = tempfile::tempdir().map_err(|source| NewgitError::io(workspace, source))?;
247        let index = scratch.path().join("index");
248        let Some(index) = index.to_str() else {
249            return Err(NewgitError::NonUtf8Path(index.display().to_string()));
250        };
251        let env = [("GIT_INDEX_FILE".to_owned(), index.to_owned())];
252
253        let ws = workspace.as_str();
254        run_git_env(&["-C", ws, "read-tree", "HEAD"], &env)?;
255        if !rendered.is_empty() {
256            let mut args: Vec<&str> = vec!["-C", ws, "update-index", "--skip-worktree", "--"];
257            args.extend(rendered.iter().map(|path| path.as_str()));
258            run_git_env(&args, &env)?;
259        }
260        run_git_env(&["-C", ws, "add", "-A"], &env)?;
261        let tree = run_git_env(&["-C", ws, "write-tree"], &env)?;
262
263        let head_tree = run_git(&["-C", ws, "rev-parse", "HEAD^{tree}"])?;
264        if tree == head_tree {
265            return Ok(None);
266        }
267
268        // A synthetic commit needs an identity even where none is configured.
269        let ident = [
270            ("GIT_AUTHOR_NAME".to_owned(), "newgit".to_owned()),
271            ("GIT_AUTHOR_EMAIL".to_owned(), "newgit@localhost".to_owned()),
272            ("GIT_COMMITTER_NAME".to_owned(), "newgit".to_owned()),
273            (
274                "GIT_COMMITTER_EMAIL".to_owned(),
275                "newgit@localhost".to_owned(),
276            ),
277        ];
278        run_git_env(
279            &["-C", ws, "commit-tree", &tree, "-p", "HEAD", "-m", message],
280            &ident,
281        )
282        .map(Some)
283    }
284
285    pub fn workspace_update_ref(workspace: &Utf8Path, name: &str, rev: &str) -> Result<()> {
286        run_git(&["-C", workspace.as_str(), "update-ref", name, rev]).map(|_| ())
287    }
288
289    pub fn workspace_delete_ref(workspace: &Utf8Path, name: &str) -> Result<()> {
290        run_git(&["-C", workspace.as_str(), "update-ref", "-d", name]).map(|_| ())
291    }
292
293    /// Fetch a store ref into a workspace clone (undo may need objects the
294    /// workspace has since discarded).
295    pub fn workspace_fetch_ref(
296        workspace: &Utf8Path,
297        from: &Utf8Path,
298        remote_ref: &str,
299    ) -> Result<()> {
300        run_git(&[
301            "-C",
302            workspace.as_str(),
303            "fetch",
304            "--quiet",
305            "--no-write-fetch-head",
306            "--",
307            from.as_str(),
308            remote_ref,
309        ])
310        .map(|_| ())
311    }
312
313    /// Put a workspace back to a checkpointed source state: HEAD hard-reset
314    /// to `head_rev`, untracked (non-ignored) files removed, then — when the
315    /// checkpoint carried uncommitted state — that state reapplied to the
316    /// worktree as uncommitted changes again. Ignored files (tracker paths,
317    /// node_modules) are deliberately left alone; trackers and resources own
318    /// their restoration.
319    pub fn workspace_restore_to(
320        workspace: &Utf8Path,
321        head_rev: &str,
322        dirty_rev: Option<&str>,
323    ) -> Result<()> {
324        let ws = workspace.as_str();
325        run_git(&["-C", ws, "reset", "--quiet", "--hard", head_rev])?;
326        run_git(&["-C", ws, "clean", "-fdq"])?;
327        if let Some(dirty) = dirty_rev {
328            run_git(&["-C", ws, "checkout", "--quiet", dirty, "--", "."])?;
329            // Mixed reset: index back to head_rev, so the dirty snapshot
330            // shows as uncommitted modifications/untracked files — exactly
331            // how it looked when the checkpoint was taken.
332            run_git(&["-C", ws, "reset", "--quiet", head_rev])?;
333        }
334        Ok(())
335    }
336
337    /// Every path the workspace's Git tracks, workspace-relative. This is
338    /// what "the source content of this branch" means for export: tracked
339    /// files as they stand on disk, so uncommitted edits are included and
340    /// ignored junk never is.
341    pub fn workspace_tracked_files(workspace: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
342        let listing = run_git(&["-C", workspace.as_str(), "ls-files", "-z"])?;
343        Ok(listing
344            .split('\0')
345            .filter(|entry| !entry.is_empty())
346            .map(Utf8PathBuf::from)
347            .collect())
348    }
349
350    /// Turn a directory of already-placed files into an ordinary Git
351    /// repository with one commit on `branch`. Returns the commit.
352    ///
353    /// One commit, never a history rewrite: exporting the workspace's Git
354    /// history would carry every file any past commit contained, which is
355    /// exactly the content the audience filter just excluded.
356    pub fn init_export_repo(destination: &Utf8Path, branch: &str, message: &str) -> Result<String> {
357        let dest = destination.as_str();
358        run_git(&["init", "--quiet", "-b", branch, "--", dest])?;
359        run_git(&["-C", dest, "add", "-A"])?;
360        run_git_env(
361            &["-C", dest, "commit", "--quiet", "-m", message],
362            &export_identity(destination),
363        )?;
364        run_git(&["-C", dest, "rev-parse", "HEAD"])
365    }
366
367    fn git(&self, args: &[&str]) -> Result<String> {
368        let mut full: Vec<&str> = vec!["-C", self.root.as_str()];
369        full.extend_from_slice(args);
370        run_git(&full)
371    }
372}
373
374/// Walk upward looking for a source repo root. `.jj` wins over `.git` at the
375/// same level (a colocated repo is still a jj repo).
376pub fn find_repo_root(start: &Utf8Path) -> Option<(Utf8PathBuf, SourceSubstrate)> {
377    let mut dir = Some(start);
378    while let Some(current) = dir {
379        if current.join(".jj").is_dir() {
380            return Some((current.to_path_buf(), SourceSubstrate::Jj));
381        }
382        if current.join(".git").exists() {
383            return Some((current.to_path_buf(), SourceSubstrate::Git));
384        }
385        dir = current.parent();
386    }
387    None
388}
389
390fn run_git(args: &[&str]) -> Result<String> {
391    run_git_env(args, &[])
392}
393
394/// The user's own Git identity when they have one, so an exported repo looks
395/// like their work; a newgit identity only where Git would otherwise refuse
396/// to commit at all.
397fn export_identity(destination: &Utf8Path) -> Vec<(String, String)> {
398    if run_git(&["-C", destination.as_str(), "var", "GIT_COMMITTER_IDENT"]).is_ok() {
399        return Vec::new();
400    }
401    ["AUTHOR", "COMMITTER"]
402        .iter()
403        .flat_map(|role| {
404            [
405                (format!("GIT_{role}_NAME"), "newgit".to_owned()),
406                (format!("GIT_{role}_EMAIL"), "newgit@localhost".to_owned()),
407            ]
408        })
409        .collect()
410}
411
412fn run_git_env(args: &[&str], env: &[(String, String)]) -> Result<String> {
413    let output = Command::new("git")
414        .args(args)
415        .envs(env.iter().map(|(key, value)| (key, value)))
416        .output()
417        .map_err(|source| spawn_error(args, &source))?;
418
419    if output.status.success() {
420        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
421    } else {
422        Err(NewgitError::SourceCommand {
423            command: format!("git {}", args.join(" ")),
424            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
425        })
426    }
427}
428
429fn spawn_error(args: &[&str], source: &std::io::Error) -> NewgitError {
430    NewgitError::SourceCommand {
431        command: format!("git {}", args.join(" ")),
432        stderr: source.to_string(),
433    }
434}