1use std::process::Command;
2
3use camino::{Utf8Path, Utf8PathBuf};
4
5use crate::config::SourceSubstrate;
6use crate::error::{NewgitError, Result};
7
8#[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 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 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 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 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 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 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 pub fn workspace_short_head(workspace: &Utf8Path) -> Result<String> {
168 run_git(&["-C", workspace.as_str(), "rev-parse", "--short", "HEAD"])
169 }
170
171 pub fn workspace_head(workspace: &Utf8Path) -> Result<String> {
173 run_git(&["-C", workspace.as_str(), "rev-parse", "HEAD"])
174 }
175
176 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 if !output.status.success() {
193 return Ok(None);
194 }
195 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 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 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 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 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 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 run_git(&["-C", ws, "reset", "--quiet", head_rev])?;
333 }
334 Ok(())
335 }
336
337 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 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
374pub 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
394fn 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}