Skip to content
Merged
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
40 changes: 39 additions & 1 deletion src/libs/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import {
statSync,
} from 'node:fs';
import type { Stats } from 'node:fs';
import { homedir } from 'node:os';
import { extname, join, resolve } from 'node:path';

const MARKDOWN_EXTENSIONS = ['.md', '.markdown'];
// Leading dot marks entries the directory walk skips (`.git`, `.obsidian`,
// dotfiles), matching globSync's default of not matching dot entries.
const HIDDEN_ENTRY_PREFIX = '.';
// Leading references to the home directory the shell would normally expand.
// A user quotes a glob (`markpost push '~/vault/**'`) precisely to stop the
// shell touching it, which also stops the shell expanding the `~`/`$HOME`, so
// the literal reference reaches us and must be expanded here instead.
const HOME_REFERENCE_PREFIXES = ['~', '$HOME', '${HOME}'];

// The outcome of expanding the raw push arguments:
// - `files`: every markdown file resolved (deduplicated, order preserved)
Expand Down Expand Up @@ -40,6 +46,38 @@ const isMarkdownFile = (filePath: string): boolean => {
return MARKDOWN_EXTENSIONS.includes(extname(filePath).toLowerCase());
};

// Expand one leading home reference against the current home directory. The
// reference on its own becomes the home directory; a `~/…` / `$HOME/…` prefix
// has the reference swapped for the home path by string splice (not `join`, so
// glob metacharacters and separators in the remainder survive untouched).
// Returns null when the prefix doesn't apply so the caller can try the next.
const expandHomeReference = (input: string, prefix: string): string | null => {
if (input === prefix) {
return homedir();
}

if (input.startsWith(`${prefix}/`)) {
return `${homedir()}${input.slice(prefix.length)}`;
}

return null;
};

// Swap a leading `~`/`$HOME` for the home directory so a quoted input resolves
// to the same target the shell would have produced unquoted. Anything without
// a home reference is returned untouched.
const expandHomeDirectory = (input: string): string => {
for (const prefix of HOME_REFERENCE_PREFIXES) {
const expanded = expandHomeReference(input, prefix);

if (expanded !== null) {
return expanded;
}
}

return input;
};

// Resolve symlinks and normalize casing so a symlink and its target, or the
// same path in different casing on a case-insensitive filesystem, share one
// key. Falls back to a lexical resolve when the path can't be realpath'd.
Expand Down Expand Up @@ -194,7 +232,7 @@ export const resolveMarkdownInputs = (
skipped: [],
visitedDirectories: new Set(),
};
resolveInput(input, accumulator);
resolveInput(expandHomeDirectory(input), accumulator);

skipped.push(...accumulator.skipped);

Expand Down
90 changes: 90 additions & 0 deletions tests/libs/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,96 @@ describe('resolveMarkdownInputs', () => {
expect(missing).toEqual([join(workspace, 'does-not-exist.md')]);
});

describe('home directory expansion', () => {
let originalHome: string | undefined;

beforeEach(() => {
originalHome = process.env.HOME;
process.env.HOME = workspace;
});

afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME;
return;
}

process.env.HOME = originalHome;
});

it('expands a leading ~ so a quoted glob matches under home', () => {
const note = createFile('vault/note.md');

const { files, missing } = resolveMarkdownInputs(['~/vault/**']);

expect(files).toEqual([note]);
expect(missing).toEqual([]);
});

it('expands a leading $HOME so a quoted glob matches under home', () => {
const note = createFile('vault/note.md');

const { files, missing } = resolveMarkdownInputs(['$HOME/vault/**']);

expect(files).toEqual([note]);
expect(missing).toEqual([]);
});

it('expands a leading ${HOME} so a quoted glob matches under home', () => {
const note = createFile('vault/note.md');

const { files, missing } = resolveMarkdownInputs(['${HOME}/vault/**']);

expect(files).toEqual([note]);
expect(missing).toEqual([]);
});

it('expands a bare ~ to the home directory itself', () => {
const note = createFile('note.md');

const { files } = resolveMarkdownInputs(['~']);

expect(files).toContain(note);
});

it('expands a bare $HOME to the home directory itself', () => {
const note = createFile('note.md');

const { files } = resolveMarkdownInputs(['$HOME']);

expect(files).toContain(note);
});

it('leaves a path without a home reference untouched', () => {
const note = createFile('vault/note.md');

const { files } = resolveMarkdownInputs([join(workspace, 'vault')]);

expect(files).toEqual([note]);
});

it('does not expand a ~ that is not a home reference', () => {
const { files, missing } = resolveMarkdownInputs(['~backup/note.md']);

expect(files).toEqual([]);
expect(missing).toEqual(['~backup/note.md']);
});

it('does not expand a $HOME prefix that is not a home reference', () => {
const { files, missing } = resolveMarkdownInputs(['$HOMEBREW/note.md']);

expect(files).toEqual([]);
expect(missing).toEqual(['$HOMEBREW/note.md']);
});

it('reports an unmatched home reference as the raw input, not expanded', () => {
const { files, missing } = resolveMarkdownInputs(['~/nope.md']);

expect(files).toEqual([]);
expect(missing).toEqual(['~/nope.md']);
});
});

it('returns no files when nothing resolves', () => {
const { files, missing } = resolveMarkdownInputs([
join(workspace, 'nope.md'),
Expand Down