Skip to content
8 changes: 6 additions & 2 deletions docs/reference/lore-cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1696,7 +1696,7 @@ Specific file paths are checked against the filesystem and staged if content dif

`--scan` walks the filesystem under the given paths, marks every detected modification/add/delete dirty, and stages them in one step.

**Usage:** `lore file stage [OPTIONS] <paths|--targets <file>>
**Usage:** `lore file stage [OPTIONS] [paths|--targets <file>]
stage [OPTIONS] <COMMAND>`

###### **Subcommands:**
Expand Down Expand Up @@ -1725,6 +1725,8 @@ Specific file paths are checked against the filesystem and staged if content dif
Detected changes are marked dirty and staged in a single pass. Use this when changes were made externally (without going through `lore dirty`), or to recover after losing track of dirty state. Equivalent in effect to running `lore status --scan` followed by `lore stage`, but performed in one traversal.

Without `--scan`, directory staging stages only files already marked dirty under that directory — mark them first with `lore dirty <paths>`, or run `lore status --scan` to reconcile dirty flags across a tree. Single-file stage paths are always checked against the filesystem regardless of this flag.

With `--scan` and no path, `lore` reconciles and stages the entire working tree from the repository root, matching the bulk reconciliation `lore dirty` recommends.
* `--targets <file>` — Path to a targets file containing all the paths to all files


Expand Down Expand Up @@ -2261,7 +2263,7 @@ Specific file path: checked against the filesystem and staged if its on-disk con

`--scan`: forces a filesystem walk under the given paths, marks modified, added, and deleted files dirty, and stages them in one step. Use this when changes were made externally without going through `lore dirty`, or to recover after losing track of dirty state.

**Usage:** `lore stage [OPTIONS] <paths|--targets <file>>
**Usage:** `lore stage [OPTIONS] [paths|--targets <file>]
stage [OPTIONS] <COMMAND>`

###### **Subcommands:**
Expand Down Expand Up @@ -2290,6 +2292,8 @@ Specific file path: checked against the filesystem and staged if its on-disk con
Detected changes are marked dirty and staged in a single pass. Use this when changes were made externally (without going through `lore dirty`), or to recover after losing track of dirty state. Equivalent in effect to running `lore status --scan` followed by `lore stage`, but performed in one traversal.

Without `--scan`, directory staging stages only files already marked dirty under that directory — mark them first with `lore dirty <paths>`, or run `lore status --scan` to reconcile dirty flags across a tree. Single-file stage paths are always checked against the filesystem regardless of this flag.

With `--scan` and no path, `lore` reconciles and stages the entire working tree from the repository root, matching the bulk reconciliation `lore dirty` recommends.
* `--targets <file>` — Path to a targets file containing all the paths to all files


Expand Down
23 changes: 21 additions & 2 deletions lore-client/src/cli/commands/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,11 +317,15 @@ pub struct FileStageArgs {
/// `lore dirty <paths>`, or run `lore status --scan` to reconcile
/// dirty flags across a tree. Single-file stage paths are always
/// checked against the filesystem regardless of this flag.
///
/// With `--scan` and no path, `lore` reconciles and stages the entire
/// working tree from the repository root, matching the bulk reconciliation
/// `lore dirty` recommends.
#[clap(long, action)]
scan: bool,

#[clap(flatten)]
paths: FilePathsTargetsArgs,
paths: FileOptionalPathsTargetsArgs,

#[clap(flatten)]
stage: FileStageCommandArgs,
Expand Down Expand Up @@ -1146,7 +1150,22 @@ pub fn handle_file_stage(globals: LoreGlobalArgs, args: &FileStageArgs) -> u8 {

// Standard stage
if args.stage.subcommand.is_none() {
let paths = convert_paths_and_targets(&args.paths.paths, &args.paths.targets);
let mut paths = convert_paths_and_targets(&args.paths.paths, &args.paths.targets);

// `lore stage --scan` with no path reconciles and stages the whole
// working tree, defaulting to the repository root. Without `--scan` a
// path is still required, since directory staging without a scan only
// picks up already-dirty entries and an empty path set has nothing to do.
if paths.is_empty() {
if args.scan {
paths = LoreArray::from_vec(vec![LoreString::from(".")]);
} else {
println!(
"error: a path is required; pass one or more paths, or use --scan to stage the whole tree"
);
return 1;
}
}

let stage_args = LoreFileStageArgs {
paths,
Expand Down
104 changes: 104 additions & 0 deletions lore-revision/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4722,6 +4722,30 @@ async fn apply_pending_discards(
}

let initial_ancestor = discard_node.parent;

// For a directory, discard the whole subtree below it first so its node
// slots are reclaimed; the node itself is unlinked from its parent and
// discarded by node_discard_patch below. Each child's sibling pointer is
// captured before discarding it, since discard_node repurposes that
// pointer for the block's free list.
if discard_node.is_directory() {
let mut child_ref = discard_node.child();
while let Some(child_id) = child_ref {
let child_node = state.node(repository.clone(), child_id).await?;
let next_sibling = child_node.sibling();
node_discard_recurse(
state.clone(),
repository.clone(),
child_id,
true, /* recurse */
true, /* discard */
|_, _| {},
)
.await?;
child_ref = next_sibling;
}
}

node_discard_patch(
state.clone(),
repository.clone(),
Expand Down Expand Up @@ -5644,6 +5668,32 @@ async fn emit_filesystem_subtree_deletes(
Ok(false)
}

/// Returns whether the on-disk directory at `relative_path` (resolved under
/// `repository_root`) is itself a Lore working copy — it contains its own
/// `.lore/` (or legacy `.urc/`) control directory.
///
/// Such a nested repository is an implicit boundary for the parent's
/// working-tree scan: its contents belong to the nested repository, not the
/// parent, so the parent neither descends into nor indexes it. This mirrors the
/// way git treats a nested `.git` directory as a submodule boundary rather than
/// pulling its files into the outer repository.
fn is_nested_repository_root(
repository_root: &std::path::Path,
relative_path: &RelativePath,
) -> bool {
let absolute_path = relative_path.to_absolute_path(repository_root);
absolute_path.join(DOT_LORE).is_dir() || absolute_path.join(DOT_URC).is_dir()
}

/// Match each filesystem item from `file_receiver` against `node_list` (the
/// `from` state's children) and `current_node_list` (the `current` state's
/// children), emitting changes into `changes`, marking matched entries in
/// `node_list_found`, spawning subtree-recursion tasks into `tasks`, and
/// queueing stale directory nodes into `pending_discards`. Items with no
/// match in `node_list` are buffered and processed as new adds once the
/// receiver is drained. Must only be called from [`diff_filesystem_directory`],
/// which sorts `node_list` and `current_node_list` by name beforehand — the
/// binary searches here assume that ordering.
#[allow(clippy::too_many_arguments)]
async fn diff_filesystem_directory_walk(
ctx: &DiffFilesystemContext,
Expand Down Expand Up @@ -5683,6 +5733,19 @@ async fn diff_filesystem_directory_walk(
{
index
} else {
// A new child directory that is itself a Lore working copy (it
// contains its own `.lore`/`.urc` control directory) is an implicit
// boundary: do not descend into or index it, mirroring how git
// ignores nested `.git` directories. Checked only for unmatched
// items — a directory tracked in the parent's merkle tree is by
// definition not a boundary — so the normal directory descent pays
// no extra filesystem metadata queries.
if item.metadata.is_dir()
&& is_nested_repository_root(ctx.from.repository.require_path()?, &item_path)
{
lore_trace!("Skipping nested repository root {item_path}");
continue;
}
new_file_list.push(item);
continue;
};
Expand Down Expand Up @@ -5834,6 +5897,21 @@ async fn diff_filesystem_directory_walk(
.await
});
} else if was_directory && is_directory {
// A staged never-committed directory that turns out to be a nested
// repository root is a stale "zombie" entry (indexed before the
// boundary check above existed, or a repository created inside a
// just-staged directory). Un-match it so it falls through to the
// delete pass below, which discards the never-committed subtree.
// Restricting the probe to the staged-only case keeps the normal
// committed-directory descent free of extra metadata queries.
if ctx.scan_dirty
&& !current_node_id.is_valid_node_id()
&& is_nested_repository_root(ctx.from.repository.require_path()?, &item_path)
{
lore_trace!("Discarding zombie entry for nested repository root {item_path}");
node_list_found[current_index] = false;
continue;
}
if ctx.scan_dirty && !current_node_id.is_valid_node_id() {
// Re-emit a staged dirty-add directory (in staged, absent from
// the current revision) as a single node so repeated scans stay
Expand Down Expand Up @@ -5975,6 +6053,32 @@ async fn diff_filesystem_directory_walk(
continue;
};

// A directory node that exists in state_from but neither in state_current
// (never committed) nor on disk is a reverted, uncommitted add — for
// example a nested repository root that was indexed before the boundary
// check above existed and has since been removed, together with whatever
// of its contents had been pulled into the parent tree. Reporting it as a
// `Delete` is meaningless because there is no committed base to delete
// from, and no mutation verb can clear it (the "zombie" entry). Discard
// the whole subtree so state_staged matches the filesystem instead, the
// same way a reverted single-file add is discarded below.
if ctx.scan_dirty && from_node.node.is_directory() {
let in_current = current_node_list
.children
.as_slice()
.binary_search_by(|child| child.name.cmp(&from_named_node.name))
.is_ok();
if !in_current {
lore_trace!(
"Queueing reverted uncommitted directory node {} (no entry at {}, not in current)",
from_named_node.node,
from_node.path
);
pending_discards.push(from_named_node.node);
continue;
}
}

// Emit deletes only for the materialized portion of the subtree,
// suppressing directories the filter merely descended through but never
// wrote to disk (see emit_filesystem_subtree_deletes).
Expand Down
Loading