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    /// Resolve a ref to a commit, when it exists.
124    pub fn ref_rev(&self, name: &str) -> Option<String> {
125        self.git(&[
126            "rev-parse",
127            "--verify",
128            "--quiet",
129            &format!("{name}^{{commit}}"),
130        ])
131        .ok()
132        .filter(|rev| !rev.is_empty())
133    }
134
135    pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
136        let args = [
137            "-C",
138            self.root.as_str(),
139            "merge-base",
140            "--is-ancestor",
141            ancestor,
142            descendant,
143        ];
144        let output = Command::new("git")
145            .args(args)
146            .output()
147            .map_err(|source| spawn_error(&args, &source))?;
148        Ok(output.status.success())
149    }
150
151    /// Live HEAD of a workspace clone, short form.
152    pub fn workspace_short_head(workspace: &Utf8Path) -> Result<String> {
153        run_git(&["-C", workspace.as_str(), "rev-parse", "--short", "HEAD"])
154    }
155
156    /// Live HEAD of a workspace clone, full form.
157    pub fn workspace_head(workspace: &Utf8Path) -> Result<String> {
158        run_git(&["-C", workspace.as_str(), "rev-parse", "HEAD"])
159    }
160
161    /// Snapshot uncommitted and untracked (non-ignored) workspace state as a
162    /// dangling commit on top of HEAD, without touching HEAD, the real
163    /// index, or the worktree. Returns `None` when the worktree is clean.
164    ///
165    /// Mechanics: a throwaway `GIT_INDEX_FILE` seeded from HEAD, `git add -A`
166    /// into it, `git write-tree`, and `git commit-tree` — all public
167    /// interface, nothing reaches into `.git` internals.
168    pub fn workspace_dirty_commit(workspace: &Utf8Path, message: &str) -> Result<Option<String>> {
169        let scratch = tempfile::tempdir().map_err(|source| NewgitError::io(workspace, source))?;
170        let index = scratch.path().join("index");
171        let Some(index) = index.to_str() else {
172            return Err(NewgitError::NonUtf8Path(index.display().to_string()));
173        };
174        let env = [("GIT_INDEX_FILE".to_owned(), index.to_owned())];
175
176        let ws = workspace.as_str();
177        run_git_env(&["-C", ws, "read-tree", "HEAD"], &env)?;
178        run_git_env(&["-C", ws, "add", "-A"], &env)?;
179        let tree = run_git_env(&["-C", ws, "write-tree"], &env)?;
180
181        let head_tree = run_git(&["-C", ws, "rev-parse", "HEAD^{tree}"])?;
182        if tree == head_tree {
183            return Ok(None);
184        }
185
186        // A synthetic commit needs an identity even where none is configured.
187        let ident = [
188            ("GIT_AUTHOR_NAME".to_owned(), "newgit".to_owned()),
189            ("GIT_AUTHOR_EMAIL".to_owned(), "newgit@localhost".to_owned()),
190            ("GIT_COMMITTER_NAME".to_owned(), "newgit".to_owned()),
191            (
192                "GIT_COMMITTER_EMAIL".to_owned(),
193                "newgit@localhost".to_owned(),
194            ),
195        ];
196        run_git_env(
197            &["-C", ws, "commit-tree", &tree, "-p", "HEAD", "-m", message],
198            &ident,
199        )
200        .map(Some)
201    }
202
203    pub fn workspace_update_ref(workspace: &Utf8Path, name: &str, rev: &str) -> Result<()> {
204        run_git(&["-C", workspace.as_str(), "update-ref", name, rev]).map(|_| ())
205    }
206
207    pub fn workspace_delete_ref(workspace: &Utf8Path, name: &str) -> Result<()> {
208        run_git(&["-C", workspace.as_str(), "update-ref", "-d", name]).map(|_| ())
209    }
210
211    /// Fetch a store ref into a workspace clone (undo may need objects the
212    /// workspace has since discarded).
213    pub fn workspace_fetch_ref(
214        workspace: &Utf8Path,
215        from: &Utf8Path,
216        remote_ref: &str,
217    ) -> Result<()> {
218        run_git(&[
219            "-C",
220            workspace.as_str(),
221            "fetch",
222            "--quiet",
223            "--no-write-fetch-head",
224            "--",
225            from.as_str(),
226            remote_ref,
227        ])
228        .map(|_| ())
229    }
230
231    /// Put a workspace back to a checkpointed source state: HEAD hard-reset
232    /// to `head_rev`, untracked (non-ignored) files removed, then — when the
233    /// checkpoint carried uncommitted state — that state reapplied to the
234    /// worktree as uncommitted changes again. Ignored files (tracker paths,
235    /// node_modules) are deliberately left alone; trackers and resources own
236    /// their restoration.
237    pub fn workspace_restore_to(
238        workspace: &Utf8Path,
239        head_rev: &str,
240        dirty_rev: Option<&str>,
241    ) -> Result<()> {
242        let ws = workspace.as_str();
243        run_git(&["-C", ws, "reset", "--quiet", "--hard", head_rev])?;
244        run_git(&["-C", ws, "clean", "-fdq"])?;
245        if let Some(dirty) = dirty_rev {
246            run_git(&["-C", ws, "checkout", "--quiet", dirty, "--", "."])?;
247            // Mixed reset: index back to head_rev, so the dirty snapshot
248            // shows as uncommitted modifications/untracked files — exactly
249            // how it looked when the checkpoint was taken.
250            run_git(&["-C", ws, "reset", "--quiet", head_rev])?;
251        }
252        Ok(())
253    }
254
255    /// Every path the workspace's Git tracks, workspace-relative. This is
256    /// what "the source content of this branch" means for export: tracked
257    /// files as they stand on disk, so uncommitted edits are included and
258    /// ignored junk never is.
259    pub fn workspace_tracked_files(workspace: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
260        let listing = run_git(&["-C", workspace.as_str(), "ls-files", "-z"])?;
261        Ok(listing
262            .split('\0')
263            .filter(|entry| !entry.is_empty())
264            .map(Utf8PathBuf::from)
265            .collect())
266    }
267
268    /// Turn a directory of already-placed files into an ordinary Git
269    /// repository with one commit on `branch`. Returns the commit.
270    ///
271    /// One commit, never a history rewrite: exporting the workspace's Git
272    /// history would carry every file any past commit contained, which is
273    /// exactly the content the audience filter just excluded.
274    pub fn init_export_repo(destination: &Utf8Path, branch: &str, message: &str) -> Result<String> {
275        let dest = destination.as_str();
276        run_git(&["init", "--quiet", "-b", branch, "--", dest])?;
277        run_git(&["-C", dest, "add", "-A"])?;
278        run_git_env(
279            &["-C", dest, "commit", "--quiet", "-m", message],
280            &export_identity(destination),
281        )?;
282        run_git(&["-C", dest, "rev-parse", "HEAD"])
283    }
284
285    fn git(&self, args: &[&str]) -> Result<String> {
286        let mut full: Vec<&str> = vec!["-C", self.root.as_str()];
287        full.extend_from_slice(args);
288        run_git(&full)
289    }
290}
291
292/// Walk upward looking for a source repo root. `.jj` wins over `.git` at the
293/// same level (a colocated repo is still a jj repo).
294pub fn find_repo_root(start: &Utf8Path) -> Option<(Utf8PathBuf, SourceSubstrate)> {
295    let mut dir = Some(start);
296    while let Some(current) = dir {
297        if current.join(".jj").is_dir() {
298            return Some((current.to_path_buf(), SourceSubstrate::Jj));
299        }
300        if current.join(".git").exists() {
301            return Some((current.to_path_buf(), SourceSubstrate::Git));
302        }
303        dir = current.parent();
304    }
305    None
306}
307
308fn run_git(args: &[&str]) -> Result<String> {
309    run_git_env(args, &[])
310}
311
312/// The user's own Git identity when they have one, so an exported repo looks
313/// like their work; a newgit identity only where Git would otherwise refuse
314/// to commit at all.
315fn export_identity(destination: &Utf8Path) -> Vec<(String, String)> {
316    if run_git(&["-C", destination.as_str(), "var", "GIT_COMMITTER_IDENT"]).is_ok() {
317        return Vec::new();
318    }
319    ["AUTHOR", "COMMITTER"]
320        .iter()
321        .flat_map(|role| {
322            [
323                (format!("GIT_{role}_NAME"), "newgit".to_owned()),
324                (format!("GIT_{role}_EMAIL"), "newgit@localhost".to_owned()),
325            ]
326        })
327        .collect()
328}
329
330fn run_git_env(args: &[&str], env: &[(String, String)]) -> Result<String> {
331    let output = Command::new("git")
332        .args(args)
333        .envs(env.iter().map(|(key, value)| (key, value)))
334        .output()
335        .map_err(|source| spawn_error(args, &source))?;
336
337    if output.status.success() {
338        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
339    } else {
340        Err(NewgitError::SourceCommand {
341            command: format!("git {}", args.join(" ")),
342            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
343        })
344    }
345}
346
347fn spawn_error(args: &[&str], source: &std::io::Error) -> NewgitError {
348    NewgitError::SourceCommand {
349        command: format!("git {}", args.join(" ")),
350        stderr: source.to_string(),
351    }
352}