Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { registerDoctorCommand } from './commands/doctor.js';

import { LoreError, ValidationError } from './util/errors.js';
import { shouldCheckForUpdate } from './util/update-check.js';
import { resolveLoreRoot } from './services/root-resolver.js';

/**
* Composition root: constructs all dependencies and wires them together.
Expand All @@ -70,30 +71,45 @@ async function main(): Promise<void> {
.option('--no-color', 'Disable colored output')
.option('--no-update-notifier', 'Disable update notification');

// 1. Create concrete implementations
const gitClient: IGitClient = new GitClient();
const trailerParser = new TrailerParser();
const pathResolver = new PathResolver();
const loreIdGenerator = new LoreIdGenerator();
// 1. Create bootstrap services for root discovery
const configLoader: IConfigLoader = new ConfigLoader();
const bootstrapGitClient: IGitClient = new GitClient(); // Default CWD

// 2. Resolve project root for caching and config
const loreRoot = await resolveLoreRoot(process.cwd(), configLoader, bootstrapGitClient);
const gitRoot = await bootstrapGitClient.isInsideRepo()
? await bootstrapGitClient.getRepoRoot()
: loreRoot;
const isScoped = loreRoot !== gitRoot;

// 2. Load config (best-effort: default if not found)
// 3. Load config (best-effort: default if not found)
let config;
try {
config = await configLoader.loadForPath(process.cwd());
config = await configLoader.loadForPath(loreRoot);
} catch {
// Fall back to defaults if config can't be loaded
const { DEFAULT_CONFIG } = await import('./types/config.js');
config = DEFAULT_CONFIG;
}

// 3. Update notification (fire-and-forget, respects env vars and config)
// 4. Create primary services with resolved root context
const gitClient: IGitClient = new GitClient(loreRoot);
const trailerParser = new TrailerParser();
const pathResolver = new PathResolver(process.cwd(), loreRoot);
const loreIdGenerator = new LoreIdGenerator();

// 5. Update notification (fire-and-forget, respects env vars and config)
if (shouldCheckForUpdate(config.cli.updateCheck)) {
simpleUpdateNotifier({ pkg }).catch(() => {});
}

// 4. Create services that depend on others
const atomRepository = new AtomRepository(gitClient, trailerParser, config.trailers.custom);
// 6. Create services that depend on others
const atomRepository = new AtomRepository(
gitClient,
trailerParser,
config.trailers.custom,
isScoped
);
const supersessionResolver = new SupersessionResolver();
const stalenessDetector = new StalenessDetector(gitClient, config);
const commitBuilder = new CommitBuilder(trailerParser, loreIdGenerator, config);
Expand All @@ -104,7 +120,7 @@ async function main(): Promise<void> {
const commitInputResolver = new CommitInputResolver(prompt);
const headLoreIdReader = new HeadLoreIdReader(gitClient, trailerParser);

// 5. Formatter factory (reads --format/--json from program options at call time)
// 7. Formatter factory (reads --format/--json from program options at call time)
// Memoized: the formatter is created once on first call and reused thereafter.
let cachedFormatter: IOutputFormatter | null = null;
const getFormatter = (): IOutputFormatter => {
Expand All @@ -120,7 +136,7 @@ async function main(): Promise<void> {
return cachedFormatter;
};

// 6. Register all commands with their dependencies
// 8. Register all commands with their dependencies

registerInitCommand(program, { getFormatter });

Expand Down Expand Up @@ -197,7 +213,7 @@ async function main(): Promise<void> {
getFormatter,
});

// 7. Parse and run
// 9. Parse and run
await program.parseAsync(process.argv);
}

Expand Down
27 changes: 25 additions & 2 deletions src/services/atom-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class AtomRepository {
private readonly gitClient: IGitClient,
private readonly trailerParser: TrailerParser,
private readonly customTrailerKeys: readonly string[] = [],
private readonly isScoped: boolean = false,
) {}

/**
Expand All @@ -42,6 +43,9 @@ export class AtomRepository {
}

const logArgs = ['--all', `--grep=Lore-id: ${loreId}`];
if (this.isScoped) {
logArgs.push('--', '.');
}
const rawCommits = await this.gitClient.log(logArgs);
const atoms = await this.parseRawCommits(rawCommits);

Expand All @@ -54,7 +58,11 @@ export class AtomRepository {
* Returns null if the commit has no valid Lore trailers.
*/
async findByCommitHash(hash: string): Promise<LoreAtom | null> {
const rawCommits = await this.gitClient.log(['-1', hash]);
const logArgs = ['-1', hash];
if (this.isScoped) {
logArgs.push('--', '.');
}
const rawCommits = await this.gitClient.log(logArgs);
const atoms = await this.parseRawCommits(rawCommits);
return atoms.length > 0 ? atoms[0] : null;
}
Expand All @@ -64,7 +72,11 @@ export class AtomRepository {
* Passes the range directly to git log.
*/
async findByRange(range: string): Promise<LoreAtom[]> {
const rawCommits = await this.gitClient.log([range]);
const logArgs = [range];
if (this.isScoped) {
logArgs.push('--', '.');
}
const rawCommits = await this.gitClient.log(logArgs);
return this.parseRawCommits(rawCommits);
}

Expand All @@ -84,6 +96,11 @@ export class AtomRepository {
args.push(`--max-count=${options.maxCommits}`);
}

// Implicitly scope to loreRoot if in a sub-project (monorepo)
if (this.isScoped) {
args.push('--', '.');
}

const rawCommits = await this.gitClient.log(args);
return this.parseRawCommits(rawCommits);
}
Expand All @@ -94,6 +111,12 @@ export class AtomRepository {
*/
async findByScope(scope: string, options: PathQueryOptions): Promise<LoreAtom[]> {
const logArgs = this.buildLogArgs(options);

// Implicitly scope to loreRoot if in a sub-project (monorepo)
if (this.isScoped) {
logArgs.push('--', '.');
}

const rawCommits = await this.gitClient.log(logArgs);
const atoms = await this.parseRawCommits(rawCommits);

Expand Down
10 changes: 9 additions & 1 deletion src/services/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,19 @@ export class ConfigLoader implements IConfigLoader {

while (true) {
const configPath = join(dir, CONFIG_DIR, CONFIG_FILENAME);
if (await this.fileExists(configPath)) {
const hasConfig = await this.fileExists(configPath);
if (hasConfig) {
paths.push(configPath);
if (stopAtFirst) return paths;
}

// Stop walking up if we hit a Git repository boundary.
// This prevents Lore from picking up parent configs that belong to a
// different Git history (e.g. submodules or nested repos).
if (await this.fileExists(join(dir, '.git'))) {
break;
}

const parentDir = dirname(dir);
if (parentDir === dir || dir === root) break;
dir = parentDir;
Expand Down
1 change: 1 addition & 0 deletions src/services/git-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export class GitClient implements IGitClient {
'--no-commit-id',
'--name-only',
'-r',
'--relative',
commitHash,
]);
return stdout.trim().split('\n').filter(line => line.length > 0);
Expand Down
54 changes: 48 additions & 6 deletions src/services/path-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { QueryTarget, TargetType } from '../types/query.js';
import { resolve, relative } from 'node:path';
import type { QueryTarget } from '../types/query.js';

const LINE_RANGE_PATTERN = /^(.+):(\d+)-(\d+)$/;
const SINGLE_LINE_PATTERN = /^(.+):(\d+)$/;
Expand All @@ -11,6 +12,11 @@ const GLOB_CHARS_PATTERN = /[*?]/;
* SRP: Only target -> git-args translation.
*/
export class PathResolver {
constructor(
private readonly cwd: string,
private readonly loreRoot: string,
) {}

/**
* Parse a raw target string into a QueryTarget.
*
Expand All @@ -25,7 +31,7 @@ export class PathResolver {
// Try line-range with start-end (e.g., file.ts:45-80)
const rangeMatch = LINE_RANGE_PATTERN.exec(raw);
if (rangeMatch) {
const filePath = rangeMatch[1];
const filePath = this.normalizePath(rangeMatch[1]);
const lineStart = parseInt(rangeMatch[2], 10);
const lineEnd = parseInt(rangeMatch[3], 10);
return {
Expand All @@ -40,7 +46,7 @@ export class PathResolver {
// Try single-line (e.g., file.ts:45)
const singleMatch = SINGLE_LINE_PATTERN.exec(raw);
if (singleMatch) {
const filePath = singleMatch[1];
const filePath = this.normalizePath(singleMatch[1]);
const lineStart = parseInt(singleMatch[2], 10);
return {
raw,
Expand All @@ -56,7 +62,7 @@ export class PathResolver {
return {
raw,
type: 'glob',
filePath: raw,
filePath: this.normalizePath(raw),
lineStart: null,
lineEnd: null,
};
Expand All @@ -67,7 +73,7 @@ export class PathResolver {
return {
raw,
type: 'directory',
filePath: raw,
filePath: this.normalizePath(raw),
lineStart: null,
lineEnd: null,
};
Expand All @@ -77,12 +83,48 @@ export class PathResolver {
return {
raw,
type: 'file',
filePath: raw,
filePath: this.normalizePath(raw),
lineStart: null,
lineEnd: null,
};
}

/**
* Normalize a path relative to the loreRoot.
*/
private normalizePath(rawPath: string): string {
// 1. Resolve to an absolute path based on CWD
const absolutePath = resolve(this.cwd, rawPath);

// 2. Compute relative path from loreRoot
let relPath = relative(this.loreRoot, absolutePath);

// 3. Normalize to POSIX separators and handle empty/root case
if (relPath === '') {
relPath = '.';
}

let normalized = relPath.replace(/\\/g, '/');

// 4. Preserve trailing slash if present in raw input
if (rawPath.endsWith('/') && !normalized.endsWith('/')) {
normalized += '/';
}

return normalized;
}

/**
* Convert multiple raw path strings into a combined git log argument array.
* Normalizes all paths relative to loreRoot.
*/
toGitLogArgsMulti(rawPaths: string[]): readonly string[] {
if (rawPaths.length === 0) return [];

const normalized = rawPaths.map((p) => this.normalizePath(p));
return ['--', ...normalized];
}

/**
* Convert a QueryTarget into git log arguments.
*
Expand Down
35 changes: 35 additions & 0 deletions src/services/root-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { join, dirname } from 'node:path';
import type { IConfigLoader } from '../interfaces/config-loader.js';
import type { IGitClient } from '../interfaces/git-client.js';

/**
* Resolves the root directory for Lore operations.
*
* Logic:
* 1. Walk up from CWD to find nearest .lore directory.
* 2. If not found, try to find the git repository root.
* 3. Fallback to process.cwd().
*/
export async function resolveLoreRoot(
cwd: string,
configLoader: IConfigLoader,
gitClient: IGitClient,
): Promise<string> {
try {
const configPath = await configLoader.findConfigPath(cwd);
if (configPath) {
// configPath is /path/to/project/.lore/config.toml
// dirname(configPath) is /path/to/project/.lore
// dirname(dirname(configPath)) is /path/to/project
return dirname(dirname(configPath));
}

if (await gitClient.isInsideRepo()) {
return await gitClient.getRepoRoot();
}
} catch {
// Best-effort; fallback to cwd
}

return cwd;
}
Loading
Loading