Skip to content

Commit 1b6b2e2

Browse files
committed
Sparse checkout support for refresh and pop
This relates to sparse checkout support in StGit (#195). Since libgit2 does not support git's sparse checkout feature, when getting worktree file status via the git2 API in a sparsely checked-out worktree will indicate that all known files outside the sparse checkout cone(s) are deleted. This is the first of several problems preventing `stg refresh` from working correctly in the context of a sparsely checked-out worktree. The next problem is that when building the index for the refresh, the git2 API is further tripped-up and "adds" deletion entries for all the out-of-cone files. By using `git` (via the stupid module) for status and index operations, `stg refresh` is made to work with sparse checkout. The stupid module is converted from a single file into a directory (stupid/mod.rs) in order to make room for some higher level abstractions for status and changed files. The new stupid::status module provides abstractions for handling the output of `git status --porcelain=v2`. Care is taken to minimize allocations and defer processing. Allocations are minimized by Statuses holding onto the status output buffer (the only allocation) and providing references into that buffer for its various methods. A similar approach is used in stupid::diff, which provides an abstraction over the modified file list coming from `git diff-tree --name-only`. To make `stg pop` work, worktree and index status checks are now performed using `git status` instead of libgit2. Eventually, all StGit commands will need to use this approach to avoid problems in sparse checkout worktrees.
1 parent 7db74c6 commit 1b6b2e2

File tree

12 files changed

+1191
-364
lines changed

12 files changed

+1191
-364
lines changed

src/cmd/pick.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
//! `stg pick` implementation.
44
5-
use std::{path::PathBuf, str::FromStr};
5+
use std::{
6+
path::{Path, PathBuf},
7+
str::FromStr,
8+
};
69

710
use anyhow::{anyhow, Context, Result};
811
use bstr::ByteSlice;
@@ -250,13 +253,19 @@ fn fold_picks(
250253
(commit, &parent)
251254
};
252255

253-
let pathspecs: Option<Vec<PathBuf>> = if matches.contains_id("fold") {
256+
let diff_files;
257+
258+
let pathspecs: Option<Vec<&Path>> = if matches.contains_id("fold") {
254259
matches
255260
.get_many::<PathBuf>("file")
256-
.map(|pathbufs| pathbufs.cloned().collect())
261+
.map(|pathbufs| pathbufs.map(|pathbuf| pathbuf.as_path()).collect())
257262
} else {
258263
assert!(matches.contains_id("update"));
259-
Some(stack.repo.diff_tree_files(&stack.branch_head)?)
264+
diff_files = stupid.diff_tree_files(
265+
stack.branch_head.parent(0)?.tree_id(),
266+
stack.branch_head.tree_id(),
267+
)?;
268+
Some(diff_files.iter().collect())
260269
};
261270

262271
let conflicts = !stupid

src/cmd/pop.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::{
1313
patchrange,
1414
repo::RepositoryExtended,
1515
stack::{Error, Stack, StackStateAccess},
16+
stupid::Stupid,
1617
};
1718

1819
use super::StGitCommand;
@@ -150,10 +151,14 @@ fn run(matches: &ArgMatches) -> Result<()> {
150151
let opt_keep = matches.contains_id("keep");
151152
let opt_spill = matches.contains_id("spill");
152153
repo.check_repository_state()?;
153-
repo.check_conflicts()?;
154+
155+
let stupid = repo.stupid();
156+
let statuses = stupid.statuses(None)?;
157+
158+
statuses.check_conflicts()?;
154159
stack.check_head_top_mismatch()?;
155160
if !opt_keep && !opt_spill {
156-
repo.check_index_and_worktree_clean()?;
161+
statuses.check_index_and_worktree_clean()?;
157162
}
158163

159164
let mut new_unapplied: Vec<PatchName> = vec![];

src/cmd/refresh.rs

Lines changed: 95 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ use std::{
88
};
99

1010
use anyhow::{anyhow, Result};
11-
use bstr::ByteSlice;
1211
use clap::{Arg, ArgGroup, ArgMatches, ValueHint};
1312
use indexmap::IndexSet;
1413

@@ -19,10 +18,12 @@ use crate::{
1918
index::TemporaryIndex,
2019
patchedit,
2120
patchname::PatchName,
22-
pathspec,
2321
signature::SignatureExtended,
2422
stack::{Error, Stack, StackStateAccess},
25-
stupid::Stupid,
23+
stupid::{
24+
status::{Status, StatusEntryKind, StatusOptions, Statuses},
25+
Stupid, StupidContext,
26+
},
2627
};
2728

2829
pub(super) fn get_command() -> (&'static str, super::StGitCommand) {
@@ -338,82 +339,36 @@ fn run(matches: &ArgMatches) -> Result<()> {
338339
}
339340

340341
fn determine_refresh_paths(
341-
repo: &git2::Repository,
342-
pathspecs: Option<clap::parser::ValuesRef<PathBuf>>,
342+
stupid: &StupidContext,
343+
statuses: &Statuses,
343344
patch_commit: Option<&git2::Commit>,
344-
use_submodules: bool,
345345
force: bool,
346346
) -> Result<IndexSet<PathBuf>> {
347-
let mut status_opts = git2::StatusOptions::new();
348-
status_opts.show(git2::StatusShow::IndexAndWorkdir);
349-
status_opts.exclude_submodules(!use_submodules);
350-
351-
if let Some(pathspecs) = pathspecs {
352-
let workdir = repo.workdir().expect("not a bare repository");
353-
let curdir = std::env::current_dir()?;
354-
355-
for pathspec in pathspecs {
356-
let norm_pathspec =
357-
pathspec::normalize_pathspec(workdir, &curdir, Path::new(pathspec))?;
358-
status_opts.pathspec(norm_pathspec);
359-
}
360-
}
361-
362-
let mut refresh_paths: IndexSet<PathBuf> = repo
363-
.statuses(Some(&mut status_opts))?
364-
.iter()
365-
.map(|entry| PathBuf::from(path_from_bytes(entry.path_bytes())))
366-
.collect();
367-
368-
if let Some(patch_commit) = patch_commit {
347+
let refresh_paths: IndexSet<&Path> = if let Some(patch_commit) = patch_commit {
369348
// Restrict update to the paths that were already part of the patch.
370-
let patch_tree = patch_commit.tree()?;
371-
let parent_tree = patch_commit.parent(0)?.tree()?;
372-
let mut diff_opts = git2::DiffOptions::new();
373-
diff_opts.ignore_submodules(!use_submodules);
374-
diff_opts.force_binary(true); // Less expensive(?)
375-
376-
let mut patch_paths: IndexSet<PathBuf> = IndexSet::new();
377-
378-
repo.diff_tree_to_tree(Some(&parent_tree), Some(&patch_tree), Some(&mut diff_opts))?
379-
.foreach(
380-
&mut |delta, _| {
381-
if let Some(old_path) = delta.old_file().path() {
382-
patch_paths.insert(old_path.to_owned());
383-
}
384-
if let Some(new_path) = delta.new_file().path() {
385-
patch_paths.insert(new_path.to_owned());
386-
}
387-
true
388-
},
389-
None,
390-
None,
391-
None,
392-
)?;
393-
394-
// Set intersection to determine final subset of paths.
395-
refresh_paths.retain(|path| patch_paths.contains(path));
396-
}
349+
let parent_tree_id = patch_commit.parent(0)?.tree_id();
350+
let diff_files = stupid.diff_tree_files(parent_tree_id, patch_commit.tree_id())?;
351+
let patch_paths = diff_files.iter().collect::<IndexSet<_>>();
352+
353+
statuses
354+
.iter()
355+
.filter_map(|entry| {
356+
let path = entry.path();
357+
if patch_paths.contains(path) {
358+
Some(path)
359+
} else {
360+
None
361+
}
362+
})
363+
.collect()
364+
} else {
365+
statuses.iter().map(|entry| entry.path()).collect()
366+
};
397367

398368
// Ensure no conflicts in the files to be refreshed.
399-
if repo
400-
.index()?
401-
.conflicts()?
402-
.filter_map(|maybe_entry| maybe_entry.ok())
403-
.any(|conflict| {
404-
if let (Some(our), Some(their)) = (&conflict.our, &conflict.their) {
405-
refresh_paths.contains(path_from_bytes(&our.path))
406-
|| (their.path != our.path
407-
&& refresh_paths.contains(path_from_bytes(&their.path)))
408-
} else if let Some(our) = conflict.our {
409-
refresh_paths.contains(path_from_bytes(&our.path))
410-
} else if let Some(their) = conflict.their {
411-
refresh_paths.contains(path_from_bytes(&their.path))
412-
} else {
413-
false
414-
}
415-
})
416-
{
369+
if statuses.iter().any(|entry| {
370+
matches!(entry.kind(), StatusEntryKind::Unmerged) && refresh_paths.contains(entry.path())
371+
}) {
417372
return Err(Error::OutstandingConflicts.into());
418373
}
419374

@@ -422,100 +377,108 @@ fn determine_refresh_paths(
422377
// If not forcing, all changes must be either in the index or worktree,
423378
// but not both.
424379
if !force {
425-
let mut status_opts = git2::StatusOptions::new();
426-
status_opts.show(git2::StatusShow::Index);
427-
status_opts.exclude_submodules(!use_submodules);
428-
let is_index_clean = repo.statuses(Some(&mut status_opts))?.is_empty();
429-
430-
if !is_index_clean {
431-
let mut status_opts = git2::StatusOptions::new();
432-
status_opts.show(git2::StatusShow::Workdir);
433-
status_opts.exclude_submodules(!use_submodules);
434-
let is_worktree_clean = repo.statuses(Some(&mut status_opts))?.is_empty();
435-
436-
if !is_worktree_clean {
380+
let mut is_index_clean = true;
381+
let mut is_worktree_clean = true;
382+
for entry in statuses.iter() {
383+
if !matches!(entry.index_status(), Status::Unmodified) {
384+
is_index_clean = false;
385+
}
386+
if !matches!(entry.worktree_status(), Status::Unmodified) {
387+
is_worktree_clean = false;
388+
}
389+
if !is_index_clean && !is_worktree_clean {
437390
return Err(anyhow!(
438391
"The index is dirty; consider using `--index` or `--force`",
439392
));
440393
}
441394
}
442395
}
443396

397+
// TODO: interrogate status once and avoid allocating PathBufs
398+
let refresh_paths = refresh_paths
399+
.iter()
400+
.map(|path| path.to_path_buf())
401+
.collect();
444402
Ok(refresh_paths)
445403
}
446404

405+
fn write_tree(
406+
stack: &Stack,
407+
refresh_paths: &IndexSet<PathBuf>,
408+
is_path_limiting: bool,
409+
) -> Result<git2::Oid> {
410+
// N.B. using temp index is necessary for the cases where there are conflicts in the
411+
// default index. I.e. by using a temp index, a subset of paths without conflicts
412+
// may be formed into a coherent tree while leaving the default index as-is.
413+
let stupid = stack.repo.stupid();
414+
if is_path_limiting {
415+
let stupid_temp = stupid.get_temp_index_context();
416+
let stupid_temp = stupid_temp.context();
417+
let tree_id_result = {
418+
stupid_temp.read_tree(stack.branch_head.tree_id())?;
419+
stupid_temp.update_index(Some(refresh_paths))?;
420+
stupid_temp.write_tree()
421+
};
422+
stupid.update_index(Some(refresh_paths))?;
423+
tree_id_result
424+
} else {
425+
if !refresh_paths.is_empty() {
426+
stupid.update_index(Some(refresh_paths))?;
427+
}
428+
stupid.write_tree()
429+
}
430+
}
431+
447432
pub(crate) fn assemble_refresh_tree(
448433
stack: &Stack,
449434
config: &git2::Config,
450435
matches: &ArgMatches,
451436
limit_to_patchname: Option<&PatchName>,
452437
) -> Result<git2::Oid> {
453-
let repo = stack.repo;
454-
let opt_submodules = matches.contains_id("submodules");
455-
let opt_nosubmodules = matches.contains_id("no-submodules");
456-
let use_submodules = if !opt_submodules && !opt_nosubmodules {
457-
config.get_bool("stgit.refreshsubmodules").unwrap_or(false)
458-
} else {
459-
opt_submodules
460-
};
438+
let stupid = stack.repo.stupid();
461439
let opt_pathspecs = matches.get_many::<PathBuf>("pathspecs");
462440
let is_path_limiting = limit_to_patchname.is_some() || opt_pathspecs.is_some();
441+
let statuses;
463442

464443
let refresh_paths = if matches.contains_id("index") {
465444
// When refreshing from the index, no path limiting may be used.
466445
assert!(!is_path_limiting);
467446
IndexSet::new()
468447
} else {
469448
let maybe_patch_commit = limit_to_patchname.map(|pn| stack.get_patch_commit(pn));
449+
let opt_submodules = matches.contains_id("submodules");
450+
let opt_nosubmodules = matches.contains_id("no-submodules");
451+
let use_submodules = if !opt_submodules && !opt_nosubmodules {
452+
config.get_bool("stgit.refreshsubmodules").unwrap_or(false)
453+
} else {
454+
opt_submodules
455+
};
456+
let mut status_opts = StatusOptions::default();
457+
status_opts.include_submodules(use_submodules);
458+
if let Some(pathspecs) = opt_pathspecs {
459+
status_opts.pathspecs(pathspecs);
460+
}
461+
statuses = stupid.statuses(Some(&status_opts))?;
462+
470463
determine_refresh_paths(
471-
repo,
472-
opt_pathspecs,
464+
&stupid,
465+
&statuses,
473466
maybe_patch_commit,
474-
use_submodules,
475467
matches.contains_id("force"),
476468
)?
477469
};
478470

479-
let tree_id = {
480-
let paths: &IndexSet<PathBuf> = &refresh_paths;
481-
let mut default_index = stack.repo.index()?;
482-
483-
// N.B. using temp index is necessary for the cases where there are conflicts in the
484-
// default index. I.e. by using a temp index, a subset of paths without conflicts
485-
// may be formed into a coherent tree while leaving the default index as-is.
486-
let tree_id_result = if is_path_limiting {
487-
let head_tree = stack.branch_head.tree()?;
488-
let tree_id_result = stack.repo.with_temp_index(|temp_index| {
489-
temp_index.read_tree(&head_tree)?;
490-
temp_index.add_all(paths, git2::IndexAddOption::DEFAULT, None)?;
491-
Ok(temp_index.write_tree()?)
492-
});
493-
494-
default_index.update_all(paths, None)?;
495-
tree_id_result
496-
} else {
497-
if !paths.is_empty() {
498-
default_index.update_all(paths, None)?;
499-
}
500-
Ok(default_index.write_tree()?)
501-
};
502-
default_index.write()?;
503-
tree_id_result
504-
}?;
471+
let tree_id = write_tree(stack, &refresh_paths, is_path_limiting)?;
505472

506-
let tree_id = if matches.contains_id("no-verify") {
473+
let tree_id = if matches.contains_id("no-verify")
474+
|| !run_pre_commit_hook(stack.repo, matches.contains_id("edit"))?
475+
|| stupid.diff_index_quiet(tree_id)?
476+
{
507477
tree_id
508478
} else {
509-
run_pre_commit_hook(repo, matches.contains_id("edit"))?;
510-
// Re-read index from filesystem because pre-commit hook may have modified it
511-
let mut index = repo.index()?;
512-
index.read(false)?;
513-
index.write_tree()?
479+
// Update index and rewrite tree if hook updated files in index
480+
write_tree(stack, &refresh_paths, is_path_limiting)?
514481
};
515482

516483
Ok(tree_id)
517484
}
518-
519-
fn path_from_bytes(b: &[u8]) -> &Path {
520-
b.to_path().expect("paths on Windows must be utf8")
521-
}

src/hook.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,24 @@ fn get_hook_path(repo: &git2::Repository, hook_name: &str) -> PathBuf {
3030
/// The `use_editor` flag determines whether the hook should be allowed to invoke an
3131
/// interactive editor.
3232
///
33-
/// Returns successfully if the hook script does not exist, is not a file, or is not
34-
/// executable.
35-
pub(crate) fn run_pre_commit_hook(repo: &git2::Repository, use_editor: bool) -> Result<()> {
33+
/// Returns `Ok(true)` if the hook ran and completed successfully, `Err()` if the hook ran but failed,
34+
/// and `Ok(false)` if the hook did not run due to the script not existing, not being a file, or not
35+
/// being executable.
36+
pub(crate) fn run_pre_commit_hook(repo: &git2::Repository, use_editor: bool) -> Result<bool> {
3637
let hook_name = "pre-commit";
3738
let hook_path = get_hook_path(repo, hook_name);
3839
let hook_meta = match std::fs::metadata(&hook_path) {
3940
Ok(meta) => meta,
40-
Err(_) => return Ok(()), // ignore missing hook
41+
Err(_) => return Ok(false), // ignore missing hook
4142
};
4243

4344
if !hook_meta.is_file() {
44-
return Ok(());
45+
return Ok(false);
4546
}
4647

4748
// Ignore non-executable hooks
4849
if !is_executable(&hook_meta) {
49-
return Ok(());
50+
return Ok(false);
5051
}
5152

5253
let mut hook_command = std::process::Command::new(hook_path);
@@ -67,7 +68,7 @@ pub(crate) fn run_pre_commit_hook(repo: &git2::Repository, use_editor: bool) ->
6768
.with_context(|| format!("`{hook_name}` hook"))?;
6869

6970
if status.success() {
70-
Ok(())
71+
Ok(true)
7172
} else {
7273
Err(anyhow!(
7374
"`{hook_name}` hook returned {}",

0 commit comments

Comments
 (0)