Skip to content

Commit e1f201d

Browse files
authored
Merge pull request #118 from KubrickCode/develop/shlee/117
feat: add modified-only mode to update only changed files
2 parents c8492cc + be56e46 commit e1f201d

11 files changed

Lines changed: 319 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ src/
106106
│ └── prompt.ts # User interaction (readline wrapper)
107107
└── utils/ # Pure utility functions
108108
├── path-helpers.ts # Path manipulation helpers
109-
└── check-existing.ts # File existence checker
109+
├── check-existing.ts # File existence checker
110+
└── file-compare.ts # File hash comparison (SHA-256)
110111
```
111112

112113
**Internal Module Organization Principles**
@@ -183,6 +184,7 @@ throw new NetworkError("Failed to download tarball", "DOWNLOAD_FAILED", {
183184
- Extract logic simplified with strategy pattern (extractDirectly/extractViaTemp)
184185
- ESLint rule enforces BaseError usage (no-restricted-syntax)
185186
- Code quality enhanced with es-toolkit utilities (partition, compact, isEmpty)
187+
- Modified-only conflict mode added (SHA-256 hash-based file comparison for selective updates)
186188

187189
**Utility Functions**
188190

@@ -220,9 +222,10 @@ See: https://es-toolkit.slash.page
220222
Uses discriminated union type `ConflictMode` to enforce mutually exclusive options:
221223

222224
- `{ mode: "force" }` - Overwrite without confirmation
223-
- `{ mode: "skip-existing" }` - Skip existing files
224-
- `{ mode: "no-clobber" }` - Abort on conflicts
225225
- `{ mode: "interactive" }` - Ask user (default)
226+
- `{ mode: "modified-only" }` - Update only modified files (ignore new files, SHA-256 hash comparison)
227+
- `{ mode: "no-clobber" }` - Abort on conflicts
228+
- `{ mode: "skip-existing" }` - Skip existing files (add new files only)
226229

227230
## Build Configuration
228231

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ baedal pull user/repo ./output --exclude "test/**" "docs/**"
3333

3434
# File conflict handling
3535
baedal pull user/repo --force # Force overwrite without confirmation
36-
baedal pull user/repo --skip-existing # Skip existing files, only add new files
36+
baedal pull user/repo --modified-only # Update only modified files (ignore new files)
3737
baedal pull user/repo --no-clobber # Abort if any file would be overwritten
38+
baedal pull user/repo --skip-existing # Skip existing files, only add new files
3839

3940
# Explicit GitHub prefix
4041
baedal pull github:user/repo
@@ -80,7 +81,7 @@ Create a GitHub Personal Access Token at Settings > Developer settings > Persona
8081
- Support for private repositories with authentication tokens
8182
- Support for specific folders/files
8283
- Exclude specific files or patterns using glob patterns
83-
- File conflict handling modes (force, skip-existing, no-clobber)
84+
- File conflict handling modes (force, modified-only, no-clobber, skip-existing)
8485
- Automatic branch detection (main/master)
8586
- Multiple input formats (prefix, URL, or simple user/repo)
8687
- Zero configuration
@@ -114,11 +115,14 @@ await baedal("user/repo", "./output", {
114115
conflictMode: { mode: "force" }, // Force overwrite without confirmation
115116
});
116117
await baedal("user/repo", "./output", {
117-
conflictMode: { mode: "skip-existing" }, // Skip existing files, only add new files
118+
conflictMode: { mode: "modified-only" }, // Update only modified files (ignore new files)
118119
});
119120
await baedal("user/repo", "./output", {
120121
conflictMode: { mode: "no-clobber" }, // Abort if any file would be overwritten
121122
});
123+
await baedal("user/repo", "./output", {
124+
conflictMode: { mode: "skip-existing" }, // Skip existing files, only add new files
125+
});
122126

123127
// Legacy conflict handling (still supported)
124128
await baedal("user/repo", "./output", {

src/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ program
4848
"Authentication token for private repositories (GitHub Personal Access Token)"
4949
)
5050
.option("-f, --force", "Force overwrite without confirmation")
51-
.option("-s, --skip-existing", "Skip existing files, only add new files")
51+
.option("-m, --modified-only", "Only update files that exist locally and have changed")
5252
.option("-n, --no-clobber", "Abort if any file would be overwritten")
53+
.option("-s, --skip-existing", "Skip existing files, only add new files")
5354
.action(async (source: string, destination: string, cliOptions: PullCLIOptions) => {
5455
try {
5556
const baedalOptions = adaptCLIOptions(cliOptions);

src/cli/adapter.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe("adaptCLIOptions", () => {
5151
};
5252

5353
expect(() => adaptCLIOptions(cliOptions)).toThrow(
54-
"Cannot use --force, --skip-existing, and --no-clobber together"
54+
"Cannot use --force, --modified-only, --no-clobber, and --skip-existing together"
5555
);
5656
});
5757

@@ -71,7 +71,7 @@ describe("adaptCLIOptions", () => {
7171
};
7272

7373
expect(() => adaptCLIOptions(cliOptions)).toThrow(
74-
"Cannot use --force, --skip-existing, and --no-clobber together"
74+
"Cannot use --force, --modified-only, --no-clobber, and --skip-existing together"
7575
);
7676
});
7777

@@ -82,19 +82,20 @@ describe("adaptCLIOptions", () => {
8282
};
8383

8484
expect(() => adaptCLIOptions(cliOptions)).toThrow(
85-
"Cannot use --force, --skip-existing, and --no-clobber together"
85+
"Cannot use --force, --modified-only, --no-clobber, and --skip-existing together"
8686
);
8787
});
8888

89-
it("should throw error when all three flags are set", () => {
89+
it("should throw error when all flags are set", () => {
9090
const cliOptions: PullCLIOptions = {
9191
force: true,
92+
modifiedOnly: true,
9293
noClobber: true,
9394
skipExisting: true,
9495
};
9596

9697
expect(() => adaptCLIOptions(cliOptions)).toThrow(
97-
"Cannot use --force, --skip-existing, and --no-clobber together"
98+
"Cannot use --force, --modified-only, --no-clobber, and --skip-existing together"
9899
);
99100
});
100101

src/cli/adapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { PullCLIOptionsSchema } from "./types";
55

66
const resolveConflictMode = (options: PullCLIOptions): ConflictMode | undefined => {
77
if (options.force) return { mode: "force" };
8-
if (options.skipExisting) return { mode: "skip-existing" };
8+
if (options.modifiedOnly) return { mode: "modified-only" };
99
if (options.noClobber || options.clobber === false) return { mode: "no-clobber" };
10+
if (options.skipExisting) return { mode: "skip-existing" };
1011
return undefined; // Library defaults to interactive
1112
};
1213

src/cli/types.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type PullCLIOptions = {
44
clobber?: boolean;
55
exclude?: string[];
66
force?: boolean;
7+
modifiedOnly?: boolean;
78
noClobber?: boolean;
89
skipExisting?: boolean;
910
token?: string;
@@ -20,21 +21,28 @@ export const PullCLIOptionsSchema = z
2021
)
2122
.optional(),
2223
force: z.boolean().optional(),
24+
modifiedOnly: z.boolean().optional(),
2325
noClobber: z.boolean().optional(),
2426
skipExisting: z.boolean().optional(),
2527
token: z.string().min(1).optional(),
2628
})
2729
.strict()
2830
.refine(
2931
(data) => {
30-
const conflictFlags = [data.force, data.skipExisting, data.noClobber].filter(Boolean);
32+
const conflictFlags = [
33+
data.force,
34+
data.modifiedOnly,
35+
data.noClobber,
36+
data.skipExisting,
37+
].filter(Boolean);
3138
return conflictFlags.length <= 1;
3239
},
3340
{
34-
message: `Cannot use --force, --skip-existing, and --no-clobber together.
41+
message: `Cannot use --force, --modified-only, --no-clobber, and --skip-existing together.
3542
Choose one conflict resolution mode:
3643
--force Overwrite without asking
37-
--skip-existing Keep existing files
38-
--no-clobber Abort if conflicts exist`,
44+
--modified-only Update only modified files
45+
--no-clobber Abort if conflicts exist
46+
--skip-existing Keep existing files`,
3947
}
4048
);
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { FileSystemError } from "../core/index";
5+
import { compareFileHash } from "./file-compare";
6+
7+
describe("compareFileHash", () => {
8+
let tempDir: string;
9+
10+
beforeEach(async () => {
11+
tempDir = await mkdtemp(join(tmpdir(), "baedal-test-"));
12+
});
13+
14+
afterEach(async () => {
15+
await rm(tempDir, { force: true, recursive: true });
16+
});
17+
18+
describe("basic functionality", () => {
19+
it("should return true for identical files", async () => {
20+
const file1 = join(tempDir, "file1.txt");
21+
const file2 = join(tempDir, "file2.txt");
22+
23+
await writeFile(file1, "Hello World");
24+
await writeFile(file2, "Hello World");
25+
26+
const result = await compareFileHash(file1, file2);
27+
expect(result).toBe(true);
28+
});
29+
30+
it("should return false for different files", async () => {
31+
const file1 = join(tempDir, "file1.txt");
32+
const file2 = join(tempDir, "file2.txt");
33+
34+
await writeFile(file1, "Hello World");
35+
await writeFile(file2, "Goodbye World");
36+
37+
const result = await compareFileHash(file1, file2);
38+
expect(result).toBe(false);
39+
});
40+
41+
it("should return true for empty files", async () => {
42+
const file1 = join(tempDir, "empty1.txt");
43+
const file2 = join(tempDir, "empty2.txt");
44+
45+
await writeFile(file1, "");
46+
await writeFile(file2, "");
47+
48+
const result = await compareFileHash(file1, file2);
49+
expect(result).toBe(true);
50+
});
51+
});
52+
53+
describe("edge cases", () => {
54+
it("should return false when files differ by a single character", async () => {
55+
const file1 = join(tempDir, "file1.txt");
56+
const file2 = join(tempDir, "file2.txt");
57+
58+
await writeFile(file1, "Hello World");
59+
await writeFile(file2, "Hello world");
60+
61+
const result = await compareFileHash(file1, file2);
62+
expect(result).toBe(false);
63+
});
64+
65+
it("should return false when files differ by whitespace", async () => {
66+
const file1 = join(tempDir, "file1.txt");
67+
const file2 = join(tempDir, "file2.txt");
68+
69+
await writeFile(file1, "Hello World");
70+
await writeFile(file2, "Hello World ");
71+
72+
const result = await compareFileHash(file1, file2);
73+
expect(result).toBe(false);
74+
});
75+
76+
it("should return false when files differ by newlines", async () => {
77+
const file1 = join(tempDir, "file1.txt");
78+
const file2 = join(tempDir, "file2.txt");
79+
80+
await writeFile(file1, "Hello\nWorld");
81+
await writeFile(file2, "Hello\r\nWorld");
82+
83+
const result = await compareFileHash(file1, file2);
84+
expect(result).toBe(false);
85+
});
86+
});
87+
88+
describe("binary files", () => {
89+
it("should correctly compare identical binary files", async () => {
90+
const file1 = join(tempDir, "binary1.bin");
91+
const file2 = join(tempDir, "binary2.bin");
92+
93+
const buffer = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]);
94+
await writeFile(file1, buffer);
95+
await writeFile(file2, buffer);
96+
97+
const result = await compareFileHash(file1, file2);
98+
expect(result).toBe(true);
99+
});
100+
101+
it("should correctly detect different binary files", async () => {
102+
const file1 = join(tempDir, "binary1.bin");
103+
const file2 = join(tempDir, "binary2.bin");
104+
105+
await writeFile(file1, Buffer.from([0x00, 0x01, 0x02]));
106+
await writeFile(file2, Buffer.from([0x00, 0x01, 0x03]));
107+
108+
const result = await compareFileHash(file1, file2);
109+
expect(result).toBe(false);
110+
});
111+
});
112+
113+
describe("large files", () => {
114+
it("should handle large files efficiently", async () => {
115+
const file1 = join(tempDir, "large1.txt");
116+
const file2 = join(tempDir, "large2.txt");
117+
118+
const largeContent = "x".repeat(1024 * 1024);
119+
await writeFile(file1, largeContent);
120+
await writeFile(file2, largeContent);
121+
122+
const result = await compareFileHash(file1, file2);
123+
expect(result).toBe(true);
124+
});
125+
126+
it("should detect differences in large files", async () => {
127+
const file1 = join(tempDir, "large1.txt");
128+
const file2 = join(tempDir, "large2.txt");
129+
130+
const largeContent1 = "x".repeat(1024 * 1024);
131+
const largeContent2 = "x".repeat(1024 * 1024 - 1) + "y";
132+
133+
await writeFile(file1, largeContent1);
134+
await writeFile(file2, largeContent2);
135+
136+
const result = await compareFileHash(file1, file2);
137+
expect(result).toBe(false);
138+
});
139+
});
140+
141+
describe("error handling", () => {
142+
it("should throw FileSystemError when first file does not exist", async () => {
143+
const nonExistentFile = join(tempDir, "nonexistent1.txt");
144+
const existingFile = join(tempDir, "existing.txt");
145+
146+
await writeFile(existingFile, "content");
147+
148+
await expect(compareFileHash(nonExistentFile, existingFile)).rejects.toThrow(FileSystemError);
149+
});
150+
151+
it("should throw FileSystemError when second file does not exist", async () => {
152+
const existingFile = join(tempDir, "existing.txt");
153+
const nonExistentFile = join(tempDir, "nonexistent2.txt");
154+
155+
await writeFile(existingFile, "content");
156+
157+
await expect(compareFileHash(existingFile, nonExistentFile)).rejects.toThrow(FileSystemError);
158+
});
159+
160+
it("should throw FileSystemError with file path context", async () => {
161+
const nonExistentFile = join(tempDir, "nonexistent.txt");
162+
const existingFile = join(tempDir, "existing.txt");
163+
164+
await writeFile(existingFile, "content");
165+
166+
await expect(compareFileHash(nonExistentFile, existingFile)).rejects.toThrow(
167+
expect.objectContaining({
168+
message: expect.stringContaining("Failed to calculate hash"),
169+
})
170+
);
171+
});
172+
});
173+
});

src/internal/utils/file-compare.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createHash } from "node:crypto";
2+
import { createReadStream } from "node:fs";
3+
import { pipeline } from "node:stream/promises";
4+
import { FileSystemError } from "../core/index";
5+
6+
const HASH_ALGORITHM = "sha256";
7+
8+
const calculateFileHash = async (filePath: string): Promise<string> => {
9+
const hash = createHash(HASH_ALGORITHM);
10+
const stream = createReadStream(filePath);
11+
12+
try {
13+
await pipeline(stream, hash);
14+
return hash.digest("hex");
15+
} catch (err) {
16+
throw new FileSystemError(
17+
`Failed to calculate hash for file: ${filePath}. ${err instanceof Error ? err.message : String(err)}`,
18+
filePath
19+
);
20+
}
21+
};
22+
23+
export const compareFileHash = async (path1: string, path2: string): Promise<boolean> => {
24+
const [hash1, hash2] = await Promise.all([calculateFileHash(path1), calculateFileHash(path2)]);
25+
return hash1 === hash2;
26+
};

src/internal/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { checkExistingFiles } from "./check-existing";
2+
export { compareFileHash } from "./file-compare";
23
export { joinPathSafe, normalizeGitHubPath, stripRootDirectory } from "./path-helpers";
34
export { parseWithZod } from "./zod-helpers";

0 commit comments

Comments
 (0)