Skip to content

Commit bc31e74

Browse files
committed
lore: Handle nested repositories as working-tree scan boundaries
A child directory that carries its own `.lore/` control directory is a nested repository — its contents belong to it, not the parent. The parent's working-tree scan (from `status --scan` or `stage --scan`) must treat it as an implicit boundary: do not descend into or index it, just as git treats a nested `.git` directory as a submodule boundary. When scanning the filesystem, skip any child directory that is itself a Lore working copy. A node previously indexed for such a directory (from before the boundary check existed) falls through to the delete pass, where it is cleared as a reverted uncommitted directory add on the next scan. Add a new optional-paths argument struct (FileOptionalPathsTargetsArgs) to support `lore stage --scan` with no path, which reconciles and stages the entire working tree from the repository root. Without `--scan`, a path remains required. Includes: a Rust test covering the nested-repository boundary (with state-based setup), plus a CLI smoke test for both nested-repository handling and `stage --scan` with no path. Signed-off-by: Huân Lê-Vương <65440815+lehuan5062@users.noreply.github.com>
1 parent 9facad1 commit bc31e74

5 files changed

Lines changed: 334 additions & 11 deletions

File tree

docs/reference/lore-cli-commands.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1696,7 +1696,7 @@ Specific file paths are checked against the filesystem and staged if content dif
16961696

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

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

17021702
###### **Subcommands:**
@@ -1725,6 +1725,8 @@ Specific file paths are checked against the filesystem and staged if content dif
17251725
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.
17261726

17271727
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.
1728+
1729+
With `--scan` and no path, `lore` reconciles and stages the entire working tree from the repository root, matching the bulk reconciliation `lore dirty` recommends.
17281730
* `--targets <file>` — Path to a targets file containing all the paths to all files
17291731

17301732

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

22622264
`--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.
22632265

2264-
**Usage:** `lore stage [OPTIONS] <paths|--targets <file>>
2266+
**Usage:** `lore stage [OPTIONS] [paths|--targets <file>]
22652267
stage [OPTIONS] <COMMAND>`
22662268

22672269
###### **Subcommands:**
@@ -2290,6 +2292,8 @@ Specific file path: checked against the filesystem and staged if its on-disk con
22902292
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.
22912293

22922294
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.
2295+
2296+
With `--scan` and no path, `lore` reconciles and stages the entire working tree from the repository root, matching the bulk reconciliation `lore dirty` recommends.
22932297
* `--targets <file>` — Path to a targets file containing all the paths to all files
22942298

22952299

lore-client/src/cli/commands/file.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,15 @@ pub struct FileStageArgs {
317317
/// `lore dirty <paths>`, or run `lore status --scan` to reconcile
318318
/// dirty flags across a tree. Single-file stage paths are always
319319
/// checked against the filesystem regardless of this flag.
320+
///
321+
/// With `--scan` and no path, `lore` reconciles and stages the entire
322+
/// working tree from the repository root, matching the bulk reconciliation
323+
/// `lore dirty` recommends.
320324
#[clap(long, action)]
321325
scan: bool,
322326

323327
#[clap(flatten)]
324-
paths: FilePathsTargetsArgs,
328+
paths: FileOptionalPathsTargetsArgs,
325329

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

11471151
// Standard stage
11481152
if args.stage.subcommand.is_none() {
1149-
let paths = convert_paths_and_targets(&args.paths.paths, &args.paths.targets);
1153+
let mut paths = convert_paths_and_targets(&args.paths.paths, &args.paths.targets);
1154+
1155+
// `lore stage --scan` with no path reconciles and stages the whole
1156+
// working tree, defaulting to the repository root. Without `--scan` a
1157+
// path is still required, since directory staging without a scan only
1158+
// picks up already-dirty entries and an empty path set has nothing to do.
1159+
if paths.is_empty() {
1160+
if args.scan {
1161+
paths = LoreArray::from_vec(vec![LoreString::from(".")]);
1162+
} else {
1163+
println!(
1164+
"error: a path is required; pass one or more paths, or use --scan to stage the whole tree"
1165+
);
1166+
return 1;
1167+
}
1168+
}
11501169

11511170
let stage_args = LoreFileStageArgs {
11521171
paths,

lore-revision/src/state.rs

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5630,6 +5630,23 @@ async fn emit_filesystem_subtree_deletes(
56305630
Ok(false)
56315631
}
56325632

5633+
/// Returns whether the on-disk directory at `relative_path` (resolved under
5634+
/// `repository_root`) is itself a Lore working copy — it contains its own
5635+
/// `.lore/` (or legacy `.urc/`) control directory.
5636+
///
5637+
/// Such a nested repository is an implicit boundary for the parent's
5638+
/// working-tree scan: its contents belong to the nested repository, not the
5639+
/// parent, so the parent neither descends into nor indexes it. This mirrors the
5640+
/// way git treats a nested `.git` directory as a submodule boundary rather than
5641+
/// pulling its files into the outer repository.
5642+
fn is_nested_repository_root(
5643+
repository_root: &std::path::Path,
5644+
relative_path: &RelativePath,
5645+
) -> bool {
5646+
let absolute_path = relative_path.to_absolute_path(repository_root);
5647+
absolute_path.join(DOT_LORE).is_dir() || absolute_path.join(DOT_URC).is_dir()
5648+
}
5649+
56335650
#[allow(clippy::too_many_arguments)]
56345651
async fn diff_filesystem_directory_walk(
56355652
ctx: &DiffFilesystemContext,
@@ -5654,6 +5671,21 @@ async fn diff_filesystem_directory_walk(
56545671
.push_into_buf(item.name.as_str())
56555672
.freeze();
56565673

5674+
// A child directory that is itself a Lore working copy (it contains its
5675+
// own `.lore`/`.urc` control directory) is an implicit boundary: do not
5676+
// descend into or index it, mirroring how git ignores nested `.git`
5677+
// directories. Skipping it keeps the nested repository's contents out of
5678+
// the parent's tree. A node previously indexed for it (from before this
5679+
// boundary check existed) is left unmatched and so falls through to the
5680+
// delete pass below, where an empty never-committed entry is discarded —
5681+
// clearing a pre-existing stale "zombie" entry on the next scan.
5682+
if item.metadata.is_dir()
5683+
&& is_nested_repository_root(ctx.from.repository.require_path()?, &item_path)
5684+
{
5685+
lore_trace!("Skipping nested repository root {item_path}");
5686+
continue;
5687+
}
5688+
56575689
if ctx.from.repository.filter.emit_excludes(
56585690
&item_path,
56595691
item.metadata.is_dir(),
@@ -5962,13 +5994,14 @@ async fn diff_filesystem_directory_walk(
59625994
};
59635995

59645996
// A directory node that exists in state_from but neither in state_current
5965-
// (never committed) nor on disk is a reverted, uncommitted add: the
5966-
// directory was staged and then removed from disk before any commit,
5967-
// together with whatever of its contents had been staged under it.
5968-
// Reporting it as a `Delete` is meaningless because there is no committed
5969-
// base to delete from, and no mutation verb can clear it (the "zombie"
5970-
// entry). Discard the whole subtree so state_staged matches the filesystem
5971-
// instead, the same way a reverted single-file add is discarded below.
5997+
// (never committed) nor on disk is a reverted, uncommitted add — for
5998+
// example a nested repository root that was indexed before the boundary
5999+
// check above existed and has since been removed, together with whatever
6000+
// of its contents had been pulled into the parent tree. Reporting it as a
6001+
// `Delete` is meaningless because there is no committed base to delete
6002+
// from, and no mutation verb can clear it (the "zombie" entry). Discard
6003+
// the whole subtree so state_staged matches the filesystem instead, the
6004+
// same way a reverted single-file add is discarded below.
59726005
if ctx.scan_dirty && from_node.node.is_directory() {
59736006
let in_current = current_node_list
59746007
.children
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// SPDX-FileCopyrightText: 2026 Epic Games, Inc.
2+
// SPDX-License-Identifier: MIT
3+
4+
//! Working-tree scan handling of a nested repository — a child directory that
5+
//! is itself a Lore working copy (it carries its own `.lore/`).
6+
//!
7+
//! A nested repository must be treated as an implicit boundary: its contents
8+
//! belong to the nested repository, not the parent, so the parent scan neither
9+
//! descends into nor indexes it. A child directory that was indexed before the
10+
//! boundary existed and is then removed must not leave an unremovable delete
11+
//! entry behind (the "zombie" entry) — the parent has no committed base it
12+
//! could be a deletion of, so the stale node is discarded on the next scan.
13+
14+
#[cfg(test)]
15+
mod tests {
16+
#![allow(clippy::disallowed_methods)] // Test fixture writes; not subject to repository write-token discipline.
17+
18+
use std::fs::File;
19+
use std::io::Write;
20+
use std::path::Path;
21+
use std::sync::Arc;
22+
23+
use lore_base::error::NoRemote;
24+
use lore_base::runtime::LORE_CONTEXT;
25+
use lore_base::runtime::runtime;
26+
use lore_base::types::Context;
27+
use lore_revision::branch;
28+
use lore_revision::filter::FilterMode;
29+
use lore_revision::lore::RepositoryId;
30+
use lore_revision::repository;
31+
use lore_revision::repository::DOT_LORE;
32+
use lore_revision::repository::RepositoryContext;
33+
use lore_revision::repository::RepositoryFormat;
34+
use lore_revision::repository::load_filter;
35+
use lore_revision::state;
36+
use lore_transport::ProtocolError;
37+
38+
include!("helper.rs");
39+
40+
/// Create (or truncate) a read/write file at `path` and write `contents` to
41+
/// it, returning the open handle. Panics if the file cannot be created or
42+
/// written, since a failed fixture setup invalidates the test.
43+
fn create_file(path: &Path, contents: &[u8]) -> File {
44+
let mut file = File::options()
45+
.create(true)
46+
.truncate(true)
47+
.read(true)
48+
.write(true)
49+
.open(path)
50+
.unwrap_or_else(|_| panic!("Failed to create test file at {}", path.display()));
51+
file.write_all(contents)
52+
.unwrap_or_else(|_| panic!("Failed to write test file at {}", path.display()));
53+
file
54+
}
55+
56+
/// Build a fresh on-disk repository at `path` with no commits (revision 0)
57+
/// and return a write-capable [`RepositoryContext`] for it.
58+
async fn create_repository(
59+
path: &Path,
60+
repository_id: RepositoryId,
61+
immutable_store: Arc<dyn lore_storage::ImmutableStore>,
62+
mutable_store: Arc<dyn lore_storage::MutableStore>,
63+
) -> Arc<RepositoryContext> {
64+
std::fs::create_dir_all(path).expect("Create repository directory failed");
65+
let default_branch = Context::from(uuid::Uuid::now_v7());
66+
let write_token = repository::RepositoryWriteToken::acquire(path).await;
67+
let created_repo = repository::create_local(
68+
path,
69+
&write_token,
70+
repository_id,
71+
default_branch,
72+
branch::DEFAULT_DEFAULT_NAME.to_string(),
73+
repository::RepositoryConfig::default(),
74+
false,
75+
)
76+
.await
77+
.expect("Failed to create repository");
78+
79+
let repository = Arc::new(
80+
RepositoryContext::new(
81+
Some(path.to_path_buf()),
82+
immutable_store,
83+
mutable_store,
84+
repository_id,
85+
created_repo.instance_id,
86+
Err(ProtocolError::from(NoRemote)),
87+
load_filter(path).expect("Failed to load filter"),
88+
RepositoryFormat::Lore,
89+
)
90+
.with_write_token(write_token.share()),
91+
);
92+
lore_revision::instance::store_current_anchor_branch(&repository, default_branch)
93+
.await
94+
.expect("Failed to store anchor branch");
95+
repository
96+
}
97+
98+
/// Reconcile the working tree against the staged state, mutating `state_staged`
99+
/// in place exactly as `lore status --scan` does, and return the detected
100+
/// changes.
101+
async fn scan(
102+
repository: Arc<RepositoryContext>,
103+
state_staged: Arc<state::State>,
104+
state_current: Arc<state::State>,
105+
) -> Vec<lore_revision::change::NodeChange> {
106+
let (changes, _stats) = state::diff_filesystem_ex(
107+
repository.clone(),
108+
state_staged,
109+
repository,
110+
state_current,
111+
None, /* full tree */
112+
FilterMode::Full,
113+
true, /* scan_dirty */
114+
Arc::new(Vec::new()),
115+
)
116+
.await
117+
.expect("Failed to diff filesystem");
118+
changes
119+
}
120+
121+
/// A child directory carrying its own `.lore/` is a nested repository: the
122+
/// parent scan must not index it or pull its contents into the parent tree.
123+
#[tokio::test]
124+
async fn nested_repository_is_not_indexed() {
125+
let (immutable_store, mutable_store, execution) =
126+
test_store_create().await.expect("Failed to create stores");
127+
let repository_id = RepositoryId::from(uuid::Uuid::now_v7());
128+
129+
runtime()
130+
.spawn(LORE_CONTEXT.scope(execution.clone(), async move {
131+
let tempdir = generate_tempdir();
132+
let path = tempdir.to_path_buf();
133+
let repository = create_repository(
134+
path.as_path(),
135+
repository_id,
136+
immutable_store.clone(),
137+
mutable_store.clone(),
138+
)
139+
.await;
140+
141+
// A tracked file in the parent so the scan has real work to do.
142+
let _ = create_file(path.join("parent_file.txt").as_path(), &[0, 1, 2, 3]);
143+
144+
// A nested repository: a child directory with its own `.lore/`
145+
// control directory and content that belongs to it, not the parent.
146+
std::fs::create_dir(path.join("nested").as_path())
147+
.expect("Create nested directory failed");
148+
std::fs::create_dir(path.join("nested").join(DOT_LORE).as_path())
149+
.expect("Create nested/.lore directory failed");
150+
let _ = create_file(path.join("nested").join("inner.txt").as_path(), &[9, 9, 9]);
151+
152+
let (current_revision, _branch) =
153+
lore_revision::instance::load_current_anchor(&repository)
154+
.await
155+
.expect("Failed to load current anchor");
156+
let state_current = state::State::deserialize(repository.clone(), current_revision)
157+
.await
158+
.expect("Failed to deserialize current state");
159+
let state_staged = state::State::deserialize(repository.clone(), current_revision)
160+
.await
161+
.expect("Failed to deserialize staged state");
162+
163+
let changes = scan(repository.clone(), state_staged, state_current).await;
164+
165+
// The parent file is indexed; nothing under the nested repository is.
166+
assert!(
167+
changes.iter().any(|c| c.path.as_str() == "parent_file.txt"),
168+
"expected the parent's own file to be indexed"
169+
);
170+
assert!(
171+
changes
172+
.iter()
173+
.all(|c| !c.path.as_str().starts_with("nested")),
174+
"nested repository contents must not be indexed, found: {:?}",
175+
changes
176+
.iter()
177+
.map(|c| c.path.as_str().to_string())
178+
.collect::<Vec<_>>()
179+
);
180+
}))
181+
.await
182+
.expect("Test task panicked");
183+
}
184+
}

0 commit comments

Comments
 (0)