Preserve source directory structure when backing up selected paths
Context
CLI users need to back up one selected file into its own directory structure without copying its sibling files. Today, selecting cooking/too.txt puts too.txt at the backup repository root. Selecting the whole cooking directory preserves its contents but also includes unrelated files.
The requested outcome is backup cooking/too.txt → <backup>/cooking/too.txt, while backup cooking continues to recursively copy the selected directory. A home-relative or home-contained absolute source must also preserve its path below the user's home directory.
Current State
Verified on 2026-10-07 at 27e4fbe935a18d117027beb6e77c73f6aa4e32f9 (main, clean working tree). The installed CLI is version 0.1.1.
src/filetree.ts:122: scanSource derives the manifest root from basename(resolved). Every manifest key is a repository-relative destination path.
src/filetree.ts:185: validateDestination checks selected entries, existing entry types, metadata containment, tracked collisions, submodules, and index flags.
src/filetree.ts:274: copySource derives source locations from the destination keys and assumes their root is the source basename. Its rescan at line 332 also assumes basename mapping.
src/backup.ts:146: the manifest flows through validation, copying, staging, commit verification, and push. source.name supplies the destination shown in output and the commit message.
test/regressions.test.ts:275: a regression test explicitly expects one/foo.md and two/foo.md to overwrite the same root-level file. This expectation must change.
src/cli.ts:9 and README.md:37 document basename-only copying.
- No destination-path resolver exists. Reuse
resolveLocalPath, isWithin, metadataAlias, the existing manifest pipeline, and the isolated Git test fixtures. No new package is required.
Proposed Change
Keep the existing command syntax: backup [--repo <directory>] <file-or-directory>. Determine the destination path from the supplied source. Do not introduce a destination flag or require an additional positional argument.
Path mapping contract
<backup> means the registered clone root. Relative inputs are interpreted from the invocation's working directory. Home means os.homedir().
| Source argument |
Destination |
cooking/too.txt |
<backup>/cooking/too.txt |
./cooking/too.txt |
<backup>/cooking/too.txt |
cooking/recipes/dinner.txt |
<backup>/cooking/recipes/dinner.txt |
cooking or cooking/ |
<backup>/cooking/ and its selected descendants |
cooking/recipes |
<backup>/cooking/recipes/ and its descendants |
~/cooking/too.txt |
<backup>/cooking/too.txt, independent of the working directory |
An absolute path below home, such as /home/example/cooking/too.txt |
<backup>/cooking/too.txt |
An absolute path outside home, such as /var/tmp/cooking/too.txt |
<backup>/too.txt, retaining the existing fallback |
. or a home-root input (~ or the absolute home directory) |
<backup>/<selected-directory-basename>/, retaining existing root-directory behavior |
For relative inputs, normalize redundant separators and . segments. Normalize .. only when the resulting source remains lexically within the invocation directory; reject a result outside that directory. The error must explain that an absolute or home-relative source can be used instead. Reject empty source strings and filesystem roots. Preserve literal filename whitespace, Unicode, and leading dashes.
Use native path parsing on each OS, with / for Git manifest keys. Preserve literal backslashes in POSIX filenames. On Windows reject ambiguous drive-relative inputs such as C:foo; support normal absolute drive and UNC paths under the same home/fallback rules. Path containment must use path boundaries, not string-prefix matching. The user-approved home behavior must work for both a literal tilde and the absolute argument produced by shell tilde expansion.
Implementation Details
- Add
src/utils/resolve-backup-path.ts after checking for equivalent existing helpers. Export a reusable resolver taking the source argument and optional explicit working-directory/home inputs for deterministic tests. Return the resolved source location and validated repository-relative destination root. Keep lexical mapping separate from canonical filesystem containment checks.
- Retain the existing
Source shape: path is the canonical-parent source location; name becomes the full repository-relative destination root; entries contains only the selected root and selected descendants, keyed by their repository-relative destinations. Resolve the mapping once. Allow scanSource to receive the previously validated destination name for an internal rescan; do not recompute mapping from an absolute path after copying.
- In
copySource, derive each origin from source.path plus the entry suffix below source.name. Do not reconstruct an origin by appending the complete destination path to dirname(source.path). Treat an entry outside the selected destination root as an internal validation error.
- Compute the ordered destination ancestor paths separately from the selected manifest. Validate every required ancestor before copying, including tracked path prefixes, submodule prefixes, and case/normalization collisions on the destination filesystem. Create missing ancestors from the clone root downward, rechecking each existing component without following symlinks. Do not scan, copy, or stage source siblings to create a parent directory.
- Apply the existing Git metadata protections to every destination component and selected source component; a path containing a metadata alias must not become acceptable merely because normalization removes that component. Retain exact
.git exclusion when traversing a selected directory. Reject destination ancestors that are files, symlinks, protected metadata, or submodules. Preserve existing leaf-symlink behavior.
- Preserve the existing lock, fetch/fast-forward, post-fetch validation, copy verification, staged-byte verification, commit verification, and push sequence. Parent checks occur after synchronization and again during copying. Verification must use the same destination keys throughout. Continue staging only selected non-directory entries.
- Display the preserved relative destination in success/error output and
chore(backup): update <relative-path> commit messages. Update help and README examples, including the different bases for relative versus home-contained absolute inputs.
Compatibility and retained behavior
This intentionally changes nested relative sources and home-contained absolute sources. Backups previously flattened into the clone root are retained; do not migrate, rename, or delete them automatically. Document that a subsequent backup can leave both the old flattened copy and the new nested copy.
Files at the same destination are overwritten as before. Destination-only files are retained. One source per invocation, hidden-file inclusion, exact .git exclusion, the 100 MiB limit, selected symlink preservation, private-repository checks, and Actions-disabled checks remain unchanged. Git does not preserve empty directories in remote history. Pending branch commits continue to be pushed according to the existing policy.
Acceptance Criteria
- Selecting
cooking/too.txt creates <backup>/cooking/too.txt with exact bytes, commits and pushes that path, and does not copy cooking/other.txt or create a root-level too.txt.
- Selecting
cooking recursively preserves nested files and directories, subject to the existing exclusions and file-type rules. Selecting cooking/recipes preserves both path components.
- Relative,
./, literal-tilde, and home-contained absolute inputs follow the mapping table. The two home forms produce the same destination even when invoked outside home.
one/foo.md and two/foo.md coexist with their respective bytes. A repeat backup updates only its mapped destination and retains unrelated committed files.
- Repeating an unchanged nested backup creates no duplicate commit. Existing pending-commit and failed-push retry behavior remains intact.
- Missing parent directories are created without including sibling files in the commit. The content can be restored from a fresh clone of the isolated remote.
- Invalid paths and pre-existing ancestor/type/metadata/submodule conflicts fail before copying payload files or pushing. A conflict discovered during copying stops the operation and follows the existing manual-recovery reporting policy.
- Parent replacement by a symlink after fetch or during copying is detected by the existing boundary model extended to all new ancestors. No payload is written through the substituted link in the deterministic regression scenarios.
- Source-change detection and staged-byte verification still work with nested mapped keys. A mismatch stops before push.
- The existing test suite passes with only the intentional basename-collision expectation changed. New scenarios pass on Linux, macOS, and Windows through the existing CI matrix; npm package acceptance runs the installed built CLI under Node.
- Help and README state the new mapping, home/outside-home behavior, ancestor restrictions, and retention of old flattened backups.
Testing Plan
Plan at least 27 targeted scenarios; OS-specific parameter variants may add more. Use literal expected values and the repository's Arrange/Act/Assert convention.
| Layer |
Scenarios |
Planned count |
| Resolver |
Relative file; leading ./; nested directory; redundant segments; whitespace/Unicode; literal tilde; absolute below home; absolute outside home; current-directory root; home root |
10 new |
| CLI with isolated Git remote |
Selected file excludes sibling; deep file creates parents; recursive directory; nested directory; tilde/absolute equivalence from another working directory; repeat/update retention; old flattened copy retained; two same-basename sources coexist |
7 new + 1 replacement |
| Safety |
Relative escape; empty source; filesystem root; metadata component; conflicting ancestor file; conflicting ancestor symlink/submodule via parameterization |
6 new |
| Regression |
Parent replacement after synchronization; mapped source mutation between scan and copy |
2 new |
| Installed package |
npm tarball → Node CLI nested-file backup → fresh clone → exact path and bytes |
1 new |
Run bun run check so the CLI bundle is rebuilt before integration tests. Confirm package acceptance coverage and the existing three-OS CI matrix. Use fixture repositories only; do not write to the user's real backup clone or global installation during development verification.
Rollback Plan
Revert the feature commit and rebuild or install the prior package version. Preserve both nested backups and any pre-existing flattened files. A rollback does not delete, flatten, or migrate backed-up data. Existing copy-failure recovery and retained-commit retry instructions continue to apply.
Effort Estimate
Estimates, not measured timings: path mapping 1–2 human hours / 15–25 agent minutes; manifest and ancestor handling 2–4 hours / 30–60 minutes; regression and package tests 2–3 hours / 30–60 minutes; docs and review 1 hour / 15–30 minutes. CI queue time is additional.
Files Reference
| File |
Change |
src/utils/resolve-backup-path.ts (new) |
Deterministic source-to-destination mapping |
src/filetree.ts:28 |
Clarify Source field semantics and preserve mapped scan keys |
src/filetree.ts:122 |
Map and validate source roots; preserve mapping on rescan |
src/filetree.ts:185 |
Preflight required ancestors and tracked prefixes |
src/filetree.ts:274 |
Create safe ancestors and correctly map source origins |
src/backup.ts:146 |
Preserve mapped-path reporting through the pipeline |
src/cli.ts:9 |
Update help and examples |
README.md:37 |
Document mapping and compatibility changes |
test/path-mapping.test.ts (new) |
Resolver contract tests |
test/backup.test.ts |
Real CLI and isolated remote behavior |
test/core.test.ts |
Ancestor and source-mutation regression coverage |
test/edge-cases.test.ts |
Invalid-path and destination-conflict coverage |
test/regressions.test.ts:275 |
Replace the flattened same-basename expectation |
test/package.test.ts:44 |
Installed Node CLI and recovery acceptance |
Out of Scope
- Destination rename/override flags, multiple sources, glob expansion, or scheduled backups.
- Mirror deletion or automatic migration of existing flattened backups.
- Changes to GitHub authentication, repository registration, transport trust, or unrelated previously recorded findings.
- Following source leaf symlinks or adding Git LFS support.
Engineering Clarifications After Quality Review
- Mapping uses native lexical absolute paths before
realpath. Normalize the explicit working directory and home first. A symlink alias does not change the displayed destination; canonical paths are used afterward for overlap and protected-destination checks. Existing source-parent symlinks remain supported, while source leaf links are copied as links.
- A normalized relative input equal to the working directory (including
cooking/..) uses that directory's basename, like .. Normalize home-relative/absolute .. segments before deciding home containment; a result outside home uses the absolute-path fallback. A literal ~other is an ordinary relative filename, not named-user expansion. Both ~/ and Windows ~\\ use the existing home expansion behavior.
- Inspect raw source components, including the invocation-directory prefix for relative paths, before normalization. Reject metadata aliases such as
.git/../cooking/too.txt. Keep the existing recursive exclusion of exact .git children inside a selected directory.
- The resolver API is
resolveBackupPath(input: string, options?: { cwd?: string; home?: string }): { path: string; name: string }. name has no leading or trailing separator. Throw ordinary Error objects, preserving the CLI's existing error exit code. Relative escape errors must mention using an absolute or home-relative path; all validation errors must identify the reason and offending path without copying payloads.
- Reuse the destination filesystem's existing case-insensitivity detection. On case-insensitive filesystems, compare NFC-normalized, lowercased keys against selected paths and every tracked-path prefix; on case-sensitive filesystems, preserve exact keys. Add explicit ancestor case/normalization collision coverage where supported.
- Check index flags only for selected tracked entries, as today; creating an ancestor must not stage or reject unrelated siblings merely because those siblings have index flags. A selected directory's own tracked descendants remain selected.
- Add tests beyond the 27-scenario minimum for Windows drive-relative rejection, native absolute/UNC parsing, literal POSIX backslashes, leading dashes, normalized current-directory inputs, and ancestor case collisions. Test parent substitution deterministically by scanning/preflighting, replacing the ancestor in the isolated fixture, then calling
copySource; no production-only test hook is required.
- Package acceptance must verify the installed entry under Node, inspect the remote tree for the exact nested path and absence of unselected paths, clone the remote freshly, and compare literal byte expectations. Reuse the package fixture and
recover helper.
src/backup.ts:225 defines manual recovery after copy/stage/commit failure; src/backup.ts:207 defines pushing the pinned commit and pending history. Retain those policies. Do not claim protection against arbitrary concurrent native filesystem mutation beyond the existing recheck model.
- Update CHANGELOG with the changed path semantics and old-copy retention. A package version bump and npm publication belong to the subsequent release workflow; the implementation and its isolated acceptance tests do not mutate the user's global installation.
Related
Preserve source directory structure when backing up selected paths
Context
CLI users need to back up one selected file into its own directory structure without copying its sibling files. Today, selecting
cooking/too.txtputstoo.txtat the backup repository root. Selecting the wholecookingdirectory preserves its contents but also includes unrelated files.The requested outcome is
backup cooking/too.txt→<backup>/cooking/too.txt, whilebackup cookingcontinues to recursively copy the selected directory. A home-relative or home-contained absolute source must also preserve its path below the user's home directory.Current State
Verified on 2026-10-07 at
27e4fbe935a18d117027beb6e77c73f6aa4e32f9(main, clean working tree). The installed CLI is version 0.1.1.src/filetree.ts:122:scanSourcederives the manifest root frombasename(resolved). Every manifest key is a repository-relative destination path.src/filetree.ts:185:validateDestinationchecks selected entries, existing entry types, metadata containment, tracked collisions, submodules, and index flags.src/filetree.ts:274:copySourcederives source locations from the destination keys and assumes their root is the source basename. Its rescan at line 332 also assumes basename mapping.src/backup.ts:146: the manifest flows through validation, copying, staging, commit verification, and push.source.namesupplies the destination shown in output and the commit message.test/regressions.test.ts:275: a regression test explicitly expectsone/foo.mdandtwo/foo.mdto overwrite the same root-level file. This expectation must change.src/cli.ts:9andREADME.md:37document basename-only copying.resolveLocalPath,isWithin,metadataAlias, the existing manifest pipeline, and the isolated Git test fixtures. No new package is required.Proposed Change
Keep the existing command syntax:
backup [--repo <directory>] <file-or-directory>. Determine the destination path from the supplied source. Do not introduce a destination flag or require an additional positional argument.Path mapping contract
<backup>means the registered clone root. Relative inputs are interpreted from the invocation's working directory. Home meansos.homedir().cooking/too.txt<backup>/cooking/too.txt./cooking/too.txt<backup>/cooking/too.txtcooking/recipes/dinner.txt<backup>/cooking/recipes/dinner.txtcookingorcooking/<backup>/cooking/and its selected descendantscooking/recipes<backup>/cooking/recipes/and its descendants~/cooking/too.txt<backup>/cooking/too.txt, independent of the working directory/home/example/cooking/too.txt<backup>/cooking/too.txt/var/tmp/cooking/too.txt<backup>/too.txt, retaining the existing fallback.or a home-root input (~or the absolute home directory)<backup>/<selected-directory-basename>/, retaining existing root-directory behaviorFor relative inputs, normalize redundant separators and
.segments. Normalize..only when the resulting source remains lexically within the invocation directory; reject a result outside that directory. The error must explain that an absolute or home-relative source can be used instead. Reject empty source strings and filesystem roots. Preserve literal filename whitespace, Unicode, and leading dashes.Use native path parsing on each OS, with
/for Git manifest keys. Preserve literal backslashes in POSIX filenames. On Windows reject ambiguous drive-relative inputs such asC:foo; support normal absolute drive and UNC paths under the same home/fallback rules. Path containment must use path boundaries, not string-prefix matching. The user-approved home behavior must work for both a literal tilde and the absolute argument produced by shell tilde expansion.Implementation Details
src/utils/resolve-backup-path.tsafter checking for equivalent existing helpers. Export a reusable resolver taking the source argument and optional explicit working-directory/home inputs for deterministic tests. Return the resolved source location and validated repository-relative destination root. Keep lexical mapping separate from canonical filesystem containment checks.Sourceshape:pathis the canonical-parent source location;namebecomes the full repository-relative destination root;entriescontains only the selected root and selected descendants, keyed by their repository-relative destinations. Resolve the mapping once. AllowscanSourceto receive the previously validated destination name for an internal rescan; do not recompute mapping from an absolute path after copying.copySource, derive each origin fromsource.pathplus the entry suffix belowsource.name. Do not reconstruct an origin by appending the complete destination path todirname(source.path). Treat an entry outside the selected destination root as an internal validation error..gitexclusion when traversing a selected directory. Reject destination ancestors that are files, symlinks, protected metadata, or submodules. Preserve existing leaf-symlink behavior.chore(backup): update <relative-path>commit messages. Update help and README examples, including the different bases for relative versus home-contained absolute inputs.Compatibility and retained behavior
This intentionally changes nested relative sources and home-contained absolute sources. Backups previously flattened into the clone root are retained; do not migrate, rename, or delete them automatically. Document that a subsequent backup can leave both the old flattened copy and the new nested copy.
Files at the same destination are overwritten as before. Destination-only files are retained. One source per invocation, hidden-file inclusion, exact
.gitexclusion, the 100 MiB limit, selected symlink preservation, private-repository checks, and Actions-disabled checks remain unchanged. Git does not preserve empty directories in remote history. Pending branch commits continue to be pushed according to the existing policy.Acceptance Criteria
cooking/too.txtcreates<backup>/cooking/too.txtwith exact bytes, commits and pushes that path, and does not copycooking/other.txtor create a root-leveltoo.txt.cookingrecursively preserves nested files and directories, subject to the existing exclusions and file-type rules. Selectingcooking/recipespreserves both path components../, literal-tilde, and home-contained absolute inputs follow the mapping table. The two home forms produce the same destination even when invoked outside home.one/foo.mdandtwo/foo.mdcoexist with their respective bytes. A repeat backup updates only its mapped destination and retains unrelated committed files.Testing Plan
Plan at least 27 targeted scenarios; OS-specific parameter variants may add more. Use literal expected values and the repository's Arrange/Act/Assert convention.
./; nested directory; redundant segments; whitespace/Unicode; literal tilde; absolute below home; absolute outside home; current-directory root; home rootRun
bun run checkso the CLI bundle is rebuilt before integration tests. Confirm package acceptance coverage and the existing three-OS CI matrix. Use fixture repositories only; do not write to the user's real backup clone or global installation during development verification.Rollback Plan
Revert the feature commit and rebuild or install the prior package version. Preserve both nested backups and any pre-existing flattened files. A rollback does not delete, flatten, or migrate backed-up data. Existing copy-failure recovery and retained-commit retry instructions continue to apply.
Effort Estimate
Estimates, not measured timings: path mapping 1–2 human hours / 15–25 agent minutes; manifest and ancestor handling 2–4 hours / 30–60 minutes; regression and package tests 2–3 hours / 30–60 minutes; docs and review 1 hour / 15–30 minutes. CI queue time is additional.
Files Reference
src/utils/resolve-backup-path.ts(new)src/filetree.ts:28Sourcefield semantics and preserve mapped scan keyssrc/filetree.ts:122src/filetree.ts:185src/filetree.ts:274src/backup.ts:146src/cli.ts:9README.md:37test/path-mapping.test.ts(new)test/backup.test.tstest/core.test.tstest/edge-cases.test.tstest/regressions.test.ts:275test/package.test.ts:44Out of Scope
Engineering Clarifications After Quality Review
realpath. Normalize the explicit working directory and home first. A symlink alias does not change the displayed destination; canonical paths are used afterward for overlap and protected-destination checks. Existing source-parent symlinks remain supported, while source leaf links are copied as links.cooking/..) uses that directory's basename, like.. Normalize home-relative/absolute..segments before deciding home containment; a result outside home uses the absolute-path fallback. A literal~otheris an ordinary relative filename, not named-user expansion. Both~/and Windows~\\use the existing home expansion behavior..git/../cooking/too.txt. Keep the existing recursive exclusion of exact.gitchildren inside a selected directory.resolveBackupPath(input: string, options?: { cwd?: string; home?: string }): { path: string; name: string }.namehas no leading or trailing separator. Throw ordinaryErrorobjects, preserving the CLI's existing error exit code. Relative escape errors must mention using an absolute or home-relative path; all validation errors must identify the reason and offending path without copying payloads.copySource; no production-only test hook is required.recoverhelper.src/backup.ts:225defines manual recovery after copy/stage/commit failure;src/backup.ts:207defines pushing the pinned commit and pending history. Retain those policies. Do not claim protection against arbitrary concurrent native filesystem mutation beyond the existing recheck model.Related
destination OR subdirectory OR relativeduring this spec session.