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 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 pub fn workspace_short_head(workspace: &Utf8Path) -> Result<String> {
153 run_git(&["-C", workspace.as_str(), "rev-parse", "--short", "HEAD"])
154 }
155
156 pub fn workspace_head(workspace: &Utf8Path) -> Result<String> {
158 run_git(&["-C", workspace.as_str(), "rev-parse", "HEAD"])
159 }
160
161 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 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 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 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 run_git(&["-C", ws, "reset", "--quiet", head_rev])?;
251 }
252 Ok(())
253 }
254
255 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 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
292pub 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
312fn 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}