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
9 changes: 5 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ Historical remote guides are excluded from the supported documentation.
from `up`, `down`, `restart`, `ps`, `logs`, `run`, `exec`, `open`, `tui`, `branch ...`,
`config get`/`set`, `env ...`, `host exec`/`shell` and `project owner`/`target`/`task`, for
example `hack ps -p <dir> --json` and `hack branch list -p <dir>`. Under `--json` it is one
error envelope; without `--json` it is one closed error line, `E_PROJECT_NOT_FOUND: <message>`,
and no stack trace. The logger adds its own prefix: the console logger (`HACK_LOGGER=console`)
prints `ERROR: E_PROJECT_NOT_FOUND: ...` on stderr, gum prints a styled `ERROR` level on
stderr, and clack prints a `■` marker on stdout after its `│` guide line. `project target` and
error envelope; without `--json` it is one closed error message, `E_PROJECT_NOT_FOUND: <message>`,
and no stack trace. The logger decides how it looks: the console logger (`HACK_LOGGER=console`)
prints one line, `ERROR: E_PROJECT_NOT_FOUND: ...`, on stderr; gum prints one line with a styled
`ERROR` level on stderr; and clack prints two lines on stdout, its `│` guide line and then the
message after a `■` marker. `project target` and
`project task` use their own message, and `target` adds a
`detail`. These commands report something else:
- `tui` without a terminal refuses for that reason first.
Expand Down
29 changes: 27 additions & 2 deletions src/lib/git-info-exclude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import { exec } from "./shell.ts";
* and is left alone instead of being replaced through the rename. When some
* write bit is set, `access(2)` decides, so a file only another user may
* write is left alone too. The mode bits are checked first, so an ACL that
* grants write access to a 0444 file does not make it writable here.
* grants write access to a 0444 file does not make it writable here. As for
* `.hack/.gitignore`, a file that vanished before the probe (ENOENT) counts
* as writable; any other probe error skips the update.
* - Concurrency: two writers can race and one block update can be lost; the
* next lifecycle command converges it again. Within a process, the work
* runs at most once per project dir.
Expand Down Expand Up @@ -158,7 +160,10 @@ async function ensureUncached(opts: {
});
return;
}
await opts.checkWriteAccess(excludePath);
await probeExcludeWriteAccess({
path: excludePath,
checkWriteAccess: opts.checkWriteAccess,
});
}
await replaceNoFollow({
dir: infoDir,
Expand All @@ -168,6 +173,26 @@ async function ensureUncached(opts: {
});
}

/**
* Runs the write probe with the same error policy as `.hack/.gitignore`
* (`isHackDirGitignoreWritable`): a file that vanished after it was read
* (ENOENT) counts as writable, since the rename recreates it. Any other probe
* error, read-only codes included, is rethrown, and the best-effort wrapper in
* {@link ensureHackGitInfoExclude} turns it into a skipped update.
*/
async function probeExcludeWriteAccess(opts: {
readonly path: string;
readonly checkWriteAccess: GitInfoExcludeWriteAccess;
}): Promise<void> {
try {
await opts.checkWriteAccess(opts.path);
} catch (error: unknown) {
if (errorCode(error) !== "ENOENT") {
throw error;
}
}
}

async function resolveExcludeLocation(opts: {
readonly projectDir: string;
}): Promise<{ readonly commonDir: string; readonly prefix: string } | null> {
Expand Down
2 changes: 1 addition & 1 deletion tests/cli-per-command-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ test.each([
);
}
// The long spelling still parses, so the CLI can name it as not valid.
expect(parse([name, "--follow"]).values).toMatchObject({ follow: true });
expect(parse([name, "--follow"]).values).toEqual({ follow: true });
});

test("config adopt --rollback stays boolean; setup migrate --rollback-manifest takes a value", () => {
Expand Down
32 changes: 31 additions & 1 deletion tests/git-info-exclude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ test.skipIf(process.platform !== "darwin")(
);
expect(acl.exitCode).toBe(0);
// The ACL makes access(W_OK) succeed; only the mode bits refuse it.
await expect(access(exclude, constants.W_OK)).resolves.toBeNull();
await access(exclude, constants.W_OK);
try {
await ensureHackGitInfoExclude({ projectDir: join(repo, ".hack") });
expect(await readFile(exclude, "utf8")).toBe("# user rule\n");
Expand Down Expand Up @@ -206,6 +206,36 @@ test("with a write bit set, the access(2) probe decides", async () => {
expect((await lstat(exclude)).mode & 0o777).toBe(0o644);
});

/**
* HACK-1412: the probe errors follow `.hack/.gitignore`. A file that vanished
* between the read and the probe (ENOENT) is recreated; any other code skips
* the update.
*/
test("a probe ENOENT counts as writable and another probe error skips", async () => {
const exclude = join(repo, ".git", "info", "exclude");
await writeFile(exclude, "# user rule\n");
await chmod(exclude, 0o644);
const failure = Object.assign(new Error("io"), { code: "EIO" });

await ensureHackGitInfoExclude({
projectDir: join(repo, ".hack"),
checkWriteAccess: () => Promise.reject(failure),
});
expect(await readFile(exclude, "utf8")).toBe("# user rule\n");

resetHackGitInfoExcludeCacheForTests();
await ensureHackGitInfoExclude({
projectDir: join(repo, ".hack"),
checkWriteAccess: async (path) => {
await rm(path);
throw Object.assign(new Error("gone"), { code: "ENOENT" });
},
});
const recreated = await readFile(exclude, "utf8");
expect(recreated).toContain("\n/.hack/.internal/\n");
expect((await lstat(exclude)).mode & 0o777).toBe(0o644);
});

test("a read-only info directory is a silent no-op, not an error", async () => {
const info = join(repo, ".git", "info");
await writeFile(join(info, "exclude"), "# user rule\n");
Expand Down
Loading