From 50a13d5857331e8844cb656025ffedb30f366ea8 Mon Sep 17 00:00:00 2001 From: Grimicorn Agent Date: Thu, 27 Aug 2026 12:24:39 -0500 Subject: [PATCH] Expand ~/$HOME in push input paths markpost push '~/vault/**' quoted the glob to stop the shell touching it, which also stopped the shell expanding the leading ~, so the literal ~ globbed against nothing. Expand a leading ~, $HOME, or ${HOME} in resolveMarkdownInputs before resolving, so quoted and unquoted inputs resolve the same target. The unmatched-input report keeps the raw path the user typed. Closes #134 --- src/libs/files.ts | 40 +++++++++++++++++- tests/libs/files.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/libs/files.ts b/src/libs/files.ts index f7ea088..bd24bc8 100644 --- a/src/libs/files.ts +++ b/src/libs/files.ts @@ -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) @@ -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. @@ -194,7 +232,7 @@ export const resolveMarkdownInputs = ( skipped: [], visitedDirectories: new Set(), }; - resolveInput(input, accumulator); + resolveInput(expandHomeDirectory(input), accumulator); skipped.push(...accumulator.skipped); diff --git a/tests/libs/files.test.ts b/tests/libs/files.test.ts index 0a83451..3d2d49d 100644 --- a/tests/libs/files.test.ts +++ b/tests/libs/files.test.ts @@ -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'),