Skip to content

Commit ac97e5c

Browse files
committed
scott-port-intent-to-add-to-git2
1 parent dc8d1d2 commit ac97e5c

1 file changed

Lines changed: 143 additions & 32 deletions

File tree

  • apps/native/src-tauri/src/git

apps/native/src-tauri/src/git/exec.rs

Lines changed: 143 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
/// - Must preserve CLI semantics exactly
99
/// - Includes hooks suppression + identity injection
1010
/// - May modify filesystem, index, HEAD, refs
11-
use crate::git::query::{has_head_commit, repo_root};
11+
use crate::git::query::has_head_commit;
1212
use anyhow::{Context, Result};
1313
use std::ffi::OsStr;
14-
use std::path::Path;
14+
use std::path::{Path, PathBuf};
1515
use std::process::{Command, Output};
1616

1717
/// Wraps git commands, errs with Stderr on exit with non-zero status
@@ -79,39 +79,74 @@ fn git_command() -> GitCommand {
7979

8080
/// Registers all untracked files as intent-to-add in the git index.
8181
/// 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`.
8287
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)?;
9889

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() {
100107
return Ok(());
101108
}
102109

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()))?;
114146
}
147+
148+
index.write().context("git2 write intent-to-add index")?;
149+
115150
Ok(())
116151
}
117152

@@ -435,10 +470,16 @@ mod tests {
435470
let second = commit_all(&repo_dir_str, "second").unwrap();
436471

437472
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+
);
439477

440478
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+
);
442483

443484
tag_commit(&repo_dir_str, "v1", &second.hash, true).unwrap();
444485
assert_eq!(
@@ -512,7 +553,10 @@ mod tests {
512553
.unwrap()
513554
.expect("expected a backup branch to be created");
514555

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+
);
516560
assert_eq!(added, "added\n");
517561

518562
let removed_path = format!("{}:remove.txt", backup_branch);
@@ -578,4 +622,71 @@ mod tests {
578622
"intent-add should index repo-root untracked file when invoked from nested config dir"
579623
);
580624
}
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+
}
581692
}

0 commit comments

Comments
 (0)