1use std::path::Path;
2
3pub struct GitStatusUtils;
5
6impl GitStatusUtils {
7 pub fn get_git_status(repo_path: &Path) -> Result<crate::GitStatus, std::io::Error> {
9 let repo = gix::discover(repo_path)
10 .map_err(|e| std::io::Error::other(format!("Failed to open git repository: {e}")))?;
11
12 let head = match repo.head_name() {
14 Ok(Some(name)) => {
15 let branch = name.as_bstr().to_string();
16 let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch);
17 crate::GitHead::Branch(branch.to_string())
18 }
19 Ok(None) => crate::GitHead::Detached,
20 Err(e) => {
21 if e.to_string().contains("does not exist") {
22 crate::GitHead::Unborn
23 } else {
24 return Err(std::io::Error::other(format!("Failed to get HEAD: {e}")));
25 }
26 }
27 };
28
29 let iter = repo
31 .status(gix::progress::Discard)
32 .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?
33 .into_index_worktree_iter(Vec::new())
34 .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
35 use gix::bstr::ByteSlice;
36 use gix::status::index_worktree::iter::Summary;
37 let mut entries = Vec::new();
38 for item_res in iter {
39 let item = item_res
40 .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
41 if let Some(summary) = item.summary() {
42 let path = item.rela_path().to_str_lossy();
43 let summary = match summary {
44 Summary::Added => crate::GitStatusSummary::Added,
45 Summary::Removed => crate::GitStatusSummary::Removed,
46 Summary::Modified => crate::GitStatusSummary::Modified,
47 Summary::TypeChange => crate::GitStatusSummary::TypeChange,
48 Summary::Renamed => crate::GitStatusSummary::Renamed,
49 Summary::Copied => crate::GitStatusSummary::Copied,
50 Summary::IntentToAdd => crate::GitStatusSummary::IntentToAdd,
51 Summary::Conflict => crate::GitStatusSummary::Conflict,
52 };
53 entries.push(crate::GitStatusEntry {
54 summary,
55 path: path.to_string(),
56 });
57 }
58 }
59
60 let mut recent_commits = Vec::new();
62 if let Ok(head_id) = repo.head_id() {
63 let oid = head_id.detach();
64 if let Ok(object) = repo.find_object(oid)
65 && let Ok(commit) = object.try_into_commit()
66 {
67 let summary_bytes = commit.message_raw_sloppy();
69 let summary = summary_bytes
70 .lines()
71 .next()
72 .and_then(|line| std::str::from_utf8(line).ok())
73 .unwrap_or("<no summary>");
74 let short_id = oid.to_hex().to_string();
75 let short_id = &short_id[..7.min(short_id.len())];
76 recent_commits.push(crate::GitCommitSummary {
77 id: short_id.to_string(),
78 summary: summary.to_string(),
79 });
80 }
81 }
82
83 Ok(crate::GitStatus::new(head, entries, recent_commits))
84 }
85}
86
87trait VcsProvider {
88 fn kind(&self) -> crate::VcsKind;
89 fn root(&self) -> &Path;
90 fn status(&self) -> Result<crate::VcsStatus, std::io::Error>;
91}
92
93struct GitProvider {
94 root: std::path::PathBuf,
95}
96
97impl VcsProvider for GitProvider {
98 fn kind(&self) -> crate::VcsKind {
99 crate::VcsKind::Git
100 }
101
102 fn root(&self) -> &Path {
103 &self.root
104 }
105
106 fn status(&self) -> Result<crate::VcsStatus, std::io::Error> {
107 GitStatusUtils::get_git_status(&self.root).map(crate::VcsStatus::Git)
108 }
109}
110
111struct JjProvider {
112 root: std::path::PathBuf,
113}
114
115impl VcsProvider for JjProvider {
116 fn kind(&self) -> crate::VcsKind {
117 crate::VcsKind::Jj
118 }
119
120 fn root(&self) -> &Path {
121 &self.root
122 }
123
124 fn status(&self) -> Result<crate::VcsStatus, std::io::Error> {
125 JjStatusUtils::get_jj_status(&self.root).map(crate::VcsStatus::Jj)
126 }
127}
128
129pub struct VcsUtils;
131
132impl VcsUtils {
133 pub fn collect_vcs_info(path: &Path) -> Option<crate::VcsInfo> {
134 let provider = Self::detect_provider(path)?;
135 let status = match provider.status() {
136 Ok(status) => status,
137 Err(err) => match provider.kind() {
138 crate::VcsKind::Git => {
139 crate::VcsStatus::Git(crate::GitStatus::unavailable(err.to_string()))
140 }
141 crate::VcsKind::Jj => {
142 crate::VcsStatus::Jj(crate::JjStatus::unavailable(err.to_string()))
143 }
144 },
145 };
146 Some(crate::VcsInfo {
147 kind: provider.kind(),
148 root: provider.root().to_path_buf(),
149 status,
150 })
151 }
152
153 fn detect_provider(path: &Path) -> Option<Box<dyn VcsProvider>> {
154 let jj_root = Self::find_marker_root(path, ".jj");
155 let git_root = Self::find_git_root(path);
156
157 match (jj_root, git_root) {
158 (Some(jj_root), Some(git_root)) => {
159 let jj_distance = Self::distance_from(path, &jj_root);
160 let git_distance = Self::distance_from(path, &git_root);
161
162 match (jj_distance, git_distance) {
163 (Some(jj_distance), Some(git_distance)) => {
164 match jj_distance.cmp(&git_distance) {
165 std::cmp::Ordering::Less | std::cmp::Ordering::Equal => {
166 Some(Box::new(JjProvider { root: jj_root }))
167 }
168 std::cmp::Ordering::Greater => {
169 Some(Box::new(GitProvider { root: git_root }))
170 }
171 }
172 }
173 (Some(_), None) => Some(Box::new(JjProvider { root: jj_root })),
174 (None, Some(_)) => Some(Box::new(GitProvider { root: git_root })),
175 (None, None) => Some(Box::new(JjProvider { root: jj_root })),
176 }
177 }
178 (Some(jj_root), None) => Some(Box::new(JjProvider { root: jj_root })),
179 (None, Some(git_root)) => Some(Box::new(GitProvider { root: git_root })),
180 (None, None) => None,
181 }
182 }
183
184 fn find_marker_root(path: &Path, marker: &str) -> Option<std::path::PathBuf> {
185 let mut current = Some(path);
186 while let Some(dir) = current {
187 if dir.join(marker).is_dir() {
188 return Some(dir.to_path_buf());
189 }
190 current = dir.parent();
191 }
192 None
193 }
194
195 fn find_git_root(path: &Path) -> Option<std::path::PathBuf> {
196 let repo = gix::discover(path).ok()?;
197 let root = repo.workdir().unwrap_or_else(|| repo.path());
198 Some(root.to_path_buf())
199 }
200
201 fn distance_from(path: &Path, root: &Path) -> Option<usize> {
202 let relative = path.strip_prefix(root).ok()?;
203 Some(relative.components().count())
204 }
205
206 #[cfg(test)]
207 fn detect_provider_for_tests(path: &Path) -> Option<Box<dyn VcsProvider>> {
208 Self::detect_provider(path)
209 }
210}
211
212struct JjStatusUtils;
213
214impl JjStatusUtils {
215 pub fn get_jj_status(workspace_root: &Path) -> Result<crate::JjStatus, std::io::Error> {
216 use jj_lib::config::{ConfigSource, StackedConfig};
217 use jj_lib::matchers::EverythingMatcher;
218 use jj_lib::object_id::ObjectId;
219 use jj_lib::repo::{Repo, StoreFactories};
220 use jj_lib::settings::UserSettings;
221 use jj_lib::workspace::{Workspace, default_working_copy_factories};
222
223 let mut config = StackedConfig::with_defaults();
224 let jj_dir = workspace_root.join(".jj");
225 let repo_config = jj_dir.join("repo").join("config.toml");
226 if repo_config.is_file() {
227 config
228 .load_file(ConfigSource::Repo, repo_config)
229 .map_err(|e| {
230 std::io::Error::other(format!("Failed to load jj repo config: {e}"))
231 })?;
232 }
233 let workspace_config = jj_dir.join("workspace-config.toml");
234 if workspace_config.is_file() {
235 config
236 .load_file(ConfigSource::Workspace, workspace_config)
237 .map_err(|e| {
238 std::io::Error::other(format!("Failed to load jj workspace config: {e}"))
239 })?;
240 }
241
242 let settings = UserSettings::from_config(config)
243 .map_err(|e| std::io::Error::other(format!("Failed to build jj settings: {e}")))?;
244 let store_factories = StoreFactories::default();
245 let working_copy_factories = default_working_copy_factories();
246 let workspace = Workspace::load(
247 &settings,
248 workspace_root,
249 &store_factories,
250 &working_copy_factories,
251 )
252 .map_err(|e| std::io::Error::other(format!("Failed to load jj workspace: {e}")))?;
253 let repo = workspace
254 .repo_loader()
255 .load_at_head()
256 .map_err(|e| std::io::Error::other(format!("Failed to load jj repo: {e}")))?;
257
258 let workspace_name = workspace.workspace_name();
259 let wc_commit_id = repo
260 .view()
261 .get_wc_commit_id(workspace_name)
262 .ok_or_else(|| {
263 std::io::Error::other(format!(
264 "No working copy commit for workspace '{}'",
265 workspace_name.as_str()
266 ))
267 })?;
268 let wc_commit = repo.store().get_commit(wc_commit_id).map_err(|e| {
269 std::io::Error::other(format!("Failed to load working copy commit: {e}"))
270 })?;
271
272 let parent_tree = wc_commit
273 .parent_tree(repo.as_ref())
274 .map_err(|e| std::io::Error::other(format!("Failed to load parent tree: {e}")))?;
275 let wc_tree = wc_commit
276 .tree()
277 .map_err(|e| std::io::Error::other(format!("Failed to load working copy tree: {e}")))?;
278 let changes = Self::collect_changes(&parent_tree, &wc_tree, &EverythingMatcher)?;
279
280 let change_id_full = wc_commit.change_id().reverse_hex();
281 let change_id = short_id(&change_id_full).to_string();
282 let commit_id_full = wc_commit.id().hex();
283 let commit_id = short_id(&commit_id_full).to_string();
284 let description = first_line(wc_commit.description()).trim();
285 let description = if description.is_empty() {
286 "(no description set)".to_string()
287 } else {
288 description.to_string()
289 };
290
291 let working_copy = crate::JjCommitSummary {
292 change_id,
293 commit_id,
294 description,
295 };
296
297 let mut parents = Vec::new();
298 let parent_ids = wc_commit.parent_ids();
299 for parent_id in parent_ids {
300 let parent_commit = repo
301 .store()
302 .get_commit(parent_id)
303 .map_err(|e| std::io::Error::other(format!("Failed to load parent commit: {e}")))?;
304 let parent_change_id_full = parent_commit.change_id().reverse_hex();
305 let parent_change_id = short_id(&parent_change_id_full).to_string();
306 let parent_commit_id_full = parent_commit.id().hex();
307 let parent_commit_id = short_id(&parent_commit_id_full).to_string();
308 let parent_description = first_line(parent_commit.description()).trim();
309 let parent_description = if parent_description.is_empty() {
310 "(no description set)".to_string()
311 } else {
312 parent_description.to_string()
313 };
314 parents.push(crate::JjCommitSummary {
315 change_id: parent_change_id,
316 commit_id: parent_commit_id,
317 description: parent_description,
318 });
319 }
320
321 Ok(crate::JjStatus::new(changes, working_copy, parents))
322 }
323
324 fn collect_changes(
325 parent_tree: &jj_lib::merged_tree::MergedTree,
326 wc_tree: &jj_lib::merged_tree::MergedTree,
327 matcher: &jj_lib::matchers::EverythingMatcher,
328 ) -> Result<Vec<crate::JjChange>, std::io::Error> {
329 let mut changes = Vec::new();
330 for entry in jj_lib::merged_tree::TreeDiffIterator::new(
331 parent_tree.as_merge(),
332 wc_tree.as_merge(),
333 matcher,
334 ) {
335 let diff = entry.values.map_err(|e| {
336 std::io::Error::other(format!("Failed to diff working copy changes: {e}"))
337 })?;
338 if !diff.is_changed() {
339 continue;
340 }
341 let change_type = if diff.before.is_absent() && diff.after.is_present() {
342 crate::JjChangeType::Added
343 } else if diff.before.is_present() && diff.after.is_absent() {
344 crate::JjChangeType::Removed
345 } else {
346 crate::JjChangeType::Modified
347 };
348 changes.push(crate::JjChange {
349 change_type,
350 path: entry.path.as_internal_file_string().to_string(),
351 });
352 }
353 changes.sort_by(|left, right| left.path.cmp(&right.path));
354 Ok(changes)
355 }
356}
357
358fn first_line(text: &str) -> &str {
359 text.lines().next().unwrap_or("")
360}
361
362fn short_id(hex: &str) -> &str {
363 let len = hex.len().min(8);
364 &hex[..len]
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::LlmStatus;
371 use std::sync::Arc;
372 use tempfile::tempdir;
373
374 #[test]
375 fn test_vcs_detection_prefers_jj() {
376 let temp_dir = tempdir().unwrap();
377 std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
378 std::fs::create_dir(temp_dir.path().join(".jj")).unwrap();
379
380 let provider = VcsUtils::detect_provider_for_tests(temp_dir.path()).unwrap();
381 assert!(matches!(provider.kind(), crate::VcsKind::Jj));
382 }
383
384 #[test]
385 fn test_vcs_detection_prefers_closer_git() {
386 let (temp_dir, _workspace, _repo) = init_jj_workspace();
387 let git_dir = temp_dir.path().join("nested");
388 std::fs::create_dir(&git_dir).unwrap();
389 gix::init(&git_dir).unwrap();
390
391 let provider = VcsUtils::detect_provider_for_tests(&git_dir).unwrap();
392 assert!(matches!(provider.kind(), crate::VcsKind::Git));
393 }
394
395 fn jj_settings() -> jj_lib::settings::UserSettings {
396 let mut config = jj_lib::config::StackedConfig::with_defaults();
397 let overrides = jj_lib::config::ConfigLayer::parse(
398 jj_lib::config::ConfigSource::CommandArg,
399 r#"
400user.name = "Test User"
401user.email = "test@example.com"
402operation.hostname = "test-host"
403operation.username = "test-user"
404signing.behavior = "drop"
405debug.randomness-seed = 0
406debug.commit-timestamp = "2001-01-01T00:00:00Z"
407debug.operation-timestamp = "2001-01-01T00:00:00Z"
408"#,
409 )
410 .unwrap();
411 config.add_layer(overrides);
412 jj_lib::settings::UserSettings::from_config(config).unwrap()
413 }
414
415 fn init_jj_workspace() -> (
416 tempfile::TempDir,
417 jj_lib::workspace::Workspace,
418 Arc<jj_lib::repo::ReadonlyRepo>,
419 ) {
420 let temp_dir = tempdir().unwrap();
421 let settings = jj_settings();
422 let (workspace, repo) =
423 jj_lib::workspace::Workspace::init_simple(&settings, temp_dir.path()).unwrap();
424 (temp_dir, workspace, repo)
425 }
426
427 fn create_dirty_working_copy(repo: &Arc<jj_lib::repo::ReadonlyRepo>) {
428 use jj_lib::backend::{CopyId, TreeValue};
429 use jj_lib::merge::Merge;
430 use jj_lib::merged_tree::MergedTreeBuilder;
431 use jj_lib::ref_name::WorkspaceName;
432 use jj_lib::repo::Repo;
433 use jj_lib::repo_path::RepoPathBuf;
434 use std::io::Cursor;
435
436 let workspace_name = WorkspaceName::DEFAULT;
437 let wc_commit_id = repo.view().get_wc_commit_id(workspace_name).unwrap();
438 let wc_commit = repo.store().get_commit(wc_commit_id).unwrap();
439
440 let file_path = RepoPathBuf::from_internal_string("file.txt").unwrap();
441 let mut contents = Cursor::new(b"content".to_vec());
442 let runtime = tokio::runtime::Runtime::new().unwrap();
443 let file_id = runtime
444 .block_on(repo.store().write_file(file_path.as_ref(), &mut contents))
445 .unwrap();
446
447 let mut tree_builder = MergedTreeBuilder::new(repo.store().empty_merged_tree_id());
448 tree_builder.set_or_remove(
449 file_path,
450 Merge::normal(TreeValue::File {
451 id: file_id,
452 executable: false,
453 copy_id: CopyId::new(vec![]),
454 }),
455 );
456 let new_tree_id = tree_builder.write_tree(repo.store()).unwrap();
457
458 let mut tx = repo.start_transaction();
459 let new_commit = tx
460 .repo_mut()
461 .new_commit(wc_commit.parent_ids().to_vec(), new_tree_id)
462 .write()
463 .unwrap();
464 tx.repo_mut()
465 .set_wc_commit(workspace_name.to_owned(), new_commit.id().clone())
466 .unwrap();
467 tx.commit("test dirty working copy").unwrap();
468 }
469
470 #[test]
471 fn test_jj_status_clean() {
472 let (temp_dir, _workspace, _repo) = init_jj_workspace();
473
474 let status = JjStatusUtils::get_jj_status(temp_dir.path()).unwrap();
475 let expected = "\
476Working copy changes:\n<none>\nWorking copy (@): oxmtprsl 5e7ebcdf (no description set)\nParent commit (@-): zzzzzzzz 00000000 (no description set)\n";
477 assert_eq!(status.as_llm_string(), expected);
478 }
479
480 #[test]
481 fn test_jj_status_dirty_after_snapshot() {
482 let (temp_dir, _workspace, repo) = init_jj_workspace();
483 create_dirty_working_copy(&repo);
484
485 let status = JjStatusUtils::get_jj_status(temp_dir.path()).unwrap();
486 let expected = "\
487Working copy changes:\nA file.txt\nWorking copy (@): lvxkkpmk ad65a7ea (no description set)\nParent commit (@-): zzzzzzzz 00000000 (no description set)\n";
488 assert_eq!(status.as_llm_string(), expected);
489 }
490}