|
8 | 8 | /// - Must preserve CLI semantics exactly |
9 | 9 | /// - Includes hooks suppression + identity injection |
10 | 10 | /// - May modify filesystem, index, HEAD, refs |
11 | | -use crate::git::query::{has_head_commit, repo_root}; |
| 11 | +use crate::git::query::has_head_commit; |
12 | 12 | use anyhow::{Context, Result}; |
13 | 13 | use std::ffi::OsStr; |
14 | | -use std::path::Path; |
| 14 | +use std::path::{Path, PathBuf}; |
15 | 15 | use std::process::{Command, Output}; |
16 | 16 |
|
17 | 17 | /// Wraps git commands, errs with Stderr on exit with non-zero status |
@@ -79,39 +79,74 @@ fn git_command() -> GitCommand { |
79 | 79 |
|
80 | 80 | /// Registers all untracked files as intent-to-add in the git index. |
81 | 81 | /// Makes files visible to `git ls-files` (and therefore Nix flakes) |
| 82 | +/// |
| 83 | +/// Commands: |
| 84 | +/// - Simulates `git ls-files --others --exclude-standard` with `repo.statuses(...)`. |
| 85 | +/// - Simulates `git add -N -- <untracked files>` by writing empty-blob index |
| 86 | +/// entries with `IndexEntryExtendedFlag::INTENT_TO_ADD`. |
82 | 87 | pub fn intent_add_untracked(dir: &str) -> Result<()> { |
83 | | - let repo_root_dir = repo_root(dir); |
84 | | - let output = git_command() |
85 | | - .args(["ls-files", "--others", "--exclude-standard"]) |
86 | | - .current_dir(&repo_root_dir) |
87 | | - .output()?; |
88 | | - |
89 | | - if !output.status.success() { |
90 | | - let stderr = String::from_utf8_lossy(&output.stderr); |
91 | | - return Err(anyhow::anyhow!( |
92 | | - "failed to run `git ls-files --others --exclude-standard` in `{dir}`: {stderr}" |
93 | | - )); |
94 | | - } |
95 | | - |
96 | | - let untracked = String::from_utf8_lossy(&output.stdout); |
97 | | - let files: Vec<&str> = untracked.lines().filter(|l| !l.is_empty()).collect(); |
| 88 | + let repo = git2::Repository::discover(dir)?; |
98 | 89 |
|
99 | | - if files.is_empty() { |
| 90 | + let mut status_opts = git2::StatusOptions::new(); |
| 91 | + status_opts |
| 92 | + .show(git2::StatusShow::Workdir) |
| 93 | + .include_untracked(true) |
| 94 | + .include_ignored(false) |
| 95 | + .recurse_untracked_dirs(true); |
| 96 | + |
| 97 | + let statuses = repo.statuses(Some(&mut status_opts))?; |
| 98 | + let untracked_paths = statuses |
| 99 | + .iter() |
| 100 | + .filter(|entry| entry.status().is_wt_new()) |
| 101 | + // git2's status path API requires UTF-8. This seems like an ok tradeoff, since |
| 102 | + // we really shouldn't be trying to manage repos with non-UTF-8 paths. |
| 103 | + .map(|entry| entry.path().map(PathBuf::from)) |
| 104 | + .collect::<std::result::Result<Vec<_>, _>>()?; |
| 105 | + |
| 106 | + if untracked_paths.is_empty() { |
100 | 107 | return Ok(()); |
101 | 108 | } |
102 | 109 |
|
103 | | - let mut args = vec!["add", "-N", "--"]; |
104 | | - args.extend(files); |
105 | | - let add_output = git_command() |
106 | | - .args(&args) |
107 | | - .current_dir(&repo_root_dir) |
108 | | - .output()?; |
109 | | - if !add_output.status.success() { |
110 | | - let stderr = String::from_utf8_lossy(&add_output.stderr); |
111 | | - return Err(anyhow::anyhow!( |
112 | | - "failed to run `git add -N -- <untracked files>` in `{dir}`: {stderr}" |
113 | | - )); |
| 110 | + let mut index = repo.index()?; |
| 111 | + let empty_blob_id = repo.blob(&[])?; |
| 112 | + |
| 113 | + for path in &untracked_paths { |
| 114 | + // Construct the entry first so executable and symlink modes |
| 115 | + // match the working tree. |
| 116 | + // This temporarily writes the file's real blob into the object database even |
| 117 | + // though the final index entry points at the empty blob. |
| 118 | + // The extra blob is unreachable and harmless, but differs from |
| 119 | + // `git add -N` semantics and will remain until normal Git object pruning. |
| 120 | + // |
| 121 | + // `add_path` itself bypasses ignore rules so we depend on the earlier |
| 122 | + // status query to filter out ignored files. |
| 123 | + index |
| 124 | + .add_path(path) |
| 125 | + .with_context(|| format!("git2 build intent-to-add entry for `{}`", path.display()))?; |
| 126 | + |
| 127 | + let mut entry = index |
| 128 | + .get_path(path, 0) |
| 129 | + .with_context(|| format!("git2 find intent-to-add entry for `{}`", path.display()))?; |
| 130 | + |
| 131 | + // Reproduce `git add -N` file semantics. |
| 132 | + entry.id = empty_blob_id; |
| 133 | + entry.ctime = git2::IndexTime::new(0, 0); |
| 134 | + entry.mtime = git2::IndexTime::new(0, 0); |
| 135 | + entry.dev = 0; |
| 136 | + entry.ino = 0; |
| 137 | + entry.uid = 0; |
| 138 | + entry.gid = 0; |
| 139 | + entry.file_size = 0; |
| 140 | + entry.flags |= git2::IndexEntryFlag::EXTENDED.bits(); |
| 141 | + entry.flags_extended |= git2::IndexEntryExtendedFlag::INTENT_TO_ADD.bits(); |
| 142 | + |
| 143 | + index |
| 144 | + .add(&entry) |
| 145 | + .with_context(|| format!("git2 add intent-to-add entry for `{}`", path.display()))?; |
114 | 146 | } |
| 147 | + |
| 148 | + index.write().context("git2 write intent-to-add index")?; |
| 149 | + |
115 | 150 | Ok(()) |
116 | 151 | } |
117 | 152 |
|
@@ -435,10 +470,16 @@ mod tests { |
435 | 470 | let second = commit_all(&repo_dir_str, "second").unwrap(); |
436 | 471 |
|
437 | 472 | tag_commit(&repo_dir_str, "v1", &first.hash, false).unwrap(); |
438 | | - assert_eq!(run_git_ok(&repo_dir, &["rev-parse", "v1"]).trim(), first.hash); |
| 473 | + assert_eq!( |
| 474 | + run_git_ok(&repo_dir, &["rev-parse", "v1"]).trim(), |
| 475 | + first.hash |
| 476 | + ); |
439 | 477 |
|
440 | 478 | assert!(tag_commit(&repo_dir_str, "v1", &second.hash, false).is_err()); |
441 | | - assert_eq!(run_git_ok(&repo_dir, &["rev-parse", "v1"]).trim(), first.hash); |
| 479 | + assert_eq!( |
| 480 | + run_git_ok(&repo_dir, &["rev-parse", "v1"]).trim(), |
| 481 | + first.hash |
| 482 | + ); |
442 | 483 |
|
443 | 484 | tag_commit(&repo_dir_str, "v1", &second.hash, true).unwrap(); |
444 | 485 | assert_eq!( |
@@ -512,7 +553,10 @@ mod tests { |
512 | 553 | .unwrap() |
513 | 554 | .expect("expected a backup branch to be created"); |
514 | 555 |
|
515 | | - let added = run_git_ok(&repo_dir, &["show", &format!("{}:added.txt", backup_branch)]); |
| 556 | + let added = run_git_ok( |
| 557 | + &repo_dir, |
| 558 | + &["show", &format!("{}:added.txt", backup_branch)], |
| 559 | + ); |
516 | 560 | assert_eq!(added, "added\n"); |
517 | 561 |
|
518 | 562 | let removed_path = format!("{}:remove.txt", backup_branch); |
@@ -578,4 +622,71 @@ mod tests { |
578 | 622 | "intent-add should index repo-root untracked file when invoked from nested config dir" |
579 | 623 | ); |
580 | 624 | } |
| 625 | + |
| 626 | + #[test] |
| 627 | + fn test_intent_add_untracked_sets_intent_flag_without_staging_contents() { |
| 628 | + let temp_dir = TempDir::new().unwrap(); |
| 629 | + let repo_dir = temp_dir.path().join("repo"); |
| 630 | + let repo_dir_str = repo_dir.to_string_lossy().to_string(); |
| 631 | + init_repo(&repo_dir_str).unwrap(); |
| 632 | + |
| 633 | + fs::write(repo_dir.join(".gitignore"), "ignored.nix\n").unwrap(); |
| 634 | + run_git_ok(&repo_dir, &["add", "-A"]); |
| 635 | + run_git_ok(&repo_dir, &["commit", "-m", "initial commit"]); |
| 636 | + |
| 637 | + fs::write(repo_dir.join("new.nix"), "{ untracked = true; }\n").unwrap(); |
| 638 | + fs::write(repo_dir.join("ignored.nix"), "{ secret = true; }\n").unwrap(); |
| 639 | + |
| 640 | + intent_add_untracked(&repo_dir_str).unwrap(); |
| 641 | + |
| 642 | + let repo = git2::Repository::open(&repo_dir).unwrap(); |
| 643 | + let index = repo.index().unwrap(); |
| 644 | + let entry = index |
| 645 | + .get_path(Path::new("new.nix"), 0) |
| 646 | + .expect("new file should have an index entry"); |
| 647 | + |
| 648 | + assert!(git2::IndexEntryFlag::from_bits_truncate(entry.flags) |
| 649 | + .contains(git2::IndexEntryFlag::EXTENDED)); |
| 650 | + assert!( |
| 651 | + git2::IndexEntryExtendedFlag::from_bits_truncate(entry.flags_extended) |
| 652 | + .contains(git2::IndexEntryExtendedFlag::INTENT_TO_ADD) |
| 653 | + ); |
| 654 | + assert_eq!( |
| 655 | + repo.find_blob(entry.id).unwrap().content(), |
| 656 | + b"", |
| 657 | + "intent-to-add entry should point at the empty blob" |
| 658 | + ); |
| 659 | + assert!( |
| 660 | + index.get_path(Path::new("ignored.nix"), 0).is_none(), |
| 661 | + "ignored files should not be registered" |
| 662 | + ); |
| 663 | + assert_eq!( |
| 664 | + run_git_ok(&repo_dir, &["diff", "--cached", "--name-only"]), |
| 665 | + "", |
| 666 | + "intent-to-add should not stage the working file contents" |
| 667 | + ); |
| 668 | + } |
| 669 | + |
| 670 | + #[test] |
| 671 | + fn test_intent_add_untracked_works_without_head_commit() { |
| 672 | + let temp_dir = TempDir::new().unwrap(); |
| 673 | + let repo_dir = temp_dir.path().join("repo"); |
| 674 | + let repo_dir_str = repo_dir.to_string_lossy().to_string(); |
| 675 | + init_repo(&repo_dir_str).unwrap(); |
| 676 | + |
| 677 | + fs::write(repo_dir.join("flake.nix"), "{ }\n").unwrap(); |
| 678 | + |
| 679 | + intent_add_untracked(&repo_dir_str).unwrap(); |
| 680 | + |
| 681 | + let repo = git2::Repository::open(&repo_dir).unwrap(); |
| 682 | + let index = repo.index().unwrap(); |
| 683 | + let entry = index |
| 684 | + .get_path(Path::new("flake.nix"), 0) |
| 685 | + .expect("unborn repo file should have an index entry"); |
| 686 | + |
| 687 | + assert!( |
| 688 | + git2::IndexEntryExtendedFlag::from_bits_truncate(entry.flags_extended) |
| 689 | + .contains(git2::IndexEntryExtendedFlag::INTENT_TO_ADD) |
| 690 | + ); |
| 691 | + } |
581 | 692 | } |
0 commit comments